Merge branch 'mvp2'
This commit is contained in:
@@ -144,6 +144,47 @@ def delete_task(task_id: int, db: Session = Depends(get_db)):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ========== SEARCH ENDPOINT ==========
|
||||||
|
|
||||||
|
@app.get("/api/search", response_model=List[schemas.Task])
|
||||||
|
def search_tasks(
|
||||||
|
query: str,
|
||||||
|
project_ids: Optional[str] = None,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Search tasks across projects by title, description, and tags.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Search term to match against title, description, and tags
|
||||||
|
project_ids: Comma-separated list of project IDs to search in (optional, searches all if not provided)
|
||||||
|
"""
|
||||||
|
# Parse project IDs if provided
|
||||||
|
project_id_list = None
|
||||||
|
if project_ids:
|
||||||
|
try:
|
||||||
|
project_id_list = [int(pid.strip()) for pid in project_ids.split(',') if pid.strip()]
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid project_ids format")
|
||||||
|
|
||||||
|
# Build query
|
||||||
|
tasks_query = db.query(models.Task)
|
||||||
|
|
||||||
|
# Filter by project IDs if specified
|
||||||
|
if project_id_list:
|
||||||
|
tasks_query = tasks_query.filter(models.Task.project_id.in_(project_id_list))
|
||||||
|
|
||||||
|
# Search in title, description, and tags
|
||||||
|
search_term = f"%{query}%"
|
||||||
|
tasks = tasks_query.filter(
|
||||||
|
(models.Task.title.ilike(search_term)) |
|
||||||
|
(models.Task.description.ilike(search_term)) |
|
||||||
|
(models.Task.tags.contains([query])) # Exact tag match
|
||||||
|
).all()
|
||||||
|
|
||||||
|
return tasks
|
||||||
|
|
||||||
|
|
||||||
# ========== JSON IMPORT ENDPOINT ==========
|
# ========== JSON IMPORT ENDPOINT ==========
|
||||||
|
|
||||||
def _import_tasks_recursive(
|
def _import_tasks_recursive(
|
||||||
@@ -161,6 +202,9 @@ def _import_tasks_recursive(
|
|||||||
title=task_data.title,
|
title=task_data.title,
|
||||||
description=task_data.description,
|
description=task_data.description,
|
||||||
status=task_data.status,
|
status=task_data.status,
|
||||||
|
estimated_minutes=task_data.estimated_minutes,
|
||||||
|
tags=task_data.tags,
|
||||||
|
flag_color=task_data.flag_color,
|
||||||
sort_order=idx
|
sort_order=idx
|
||||||
)
|
)
|
||||||
db_task = crud.create_task(db, task)
|
db_task = crud.create_task(db, task)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum
|
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum, JSON
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import enum
|
import enum
|
||||||
@@ -34,6 +34,9 @@ class Task(Base):
|
|||||||
description = Column(Text, nullable=True)
|
description = Column(Text, nullable=True)
|
||||||
status = Column(Enum(TaskStatus), default=TaskStatus.BACKLOG, nullable=False)
|
status = Column(Enum(TaskStatus), default=TaskStatus.BACKLOG, nullable=False)
|
||||||
sort_order = Column(Integer, default=0)
|
sort_order = Column(Integer, default=0)
|
||||||
|
estimated_minutes = Column(Integer, nullable=True)
|
||||||
|
tags = Column(JSON, nullable=True)
|
||||||
|
flag_color = Column(String(50), nullable=True)
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ class TaskBase(BaseModel):
|
|||||||
status: TaskStatus = TaskStatus.BACKLOG
|
status: TaskStatus = TaskStatus.BACKLOG
|
||||||
parent_task_id: Optional[int] = None
|
parent_task_id: Optional[int] = None
|
||||||
sort_order: int = 0
|
sort_order: int = 0
|
||||||
|
estimated_minutes: Optional[int] = None
|
||||||
|
tags: Optional[List[str]] = None
|
||||||
|
flag_color: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class TaskCreate(TaskBase):
|
class TaskCreate(TaskBase):
|
||||||
@@ -23,6 +26,9 @@ class TaskUpdate(BaseModel):
|
|||||||
status: Optional[TaskStatus] = None
|
status: Optional[TaskStatus] = None
|
||||||
parent_task_id: Optional[int] = None
|
parent_task_id: Optional[int] = None
|
||||||
sort_order: Optional[int] = None
|
sort_order: Optional[int] = None
|
||||||
|
estimated_minutes: Optional[int] = None
|
||||||
|
tags: Optional[List[str]] = None
|
||||||
|
flag_color: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class Task(TaskBase):
|
class Task(TaskBase):
|
||||||
@@ -74,6 +80,9 @@ class ImportSubtask(BaseModel):
|
|||||||
title: str
|
title: str
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
status: TaskStatus = TaskStatus.BACKLOG
|
status: TaskStatus = TaskStatus.BACKLOG
|
||||||
|
estimated_minutes: Optional[int] = None
|
||||||
|
tags: Optional[List[str]] = None
|
||||||
|
flag_color: Optional[str] = None
|
||||||
subtasks: List['ImportSubtask'] = []
|
subtasks: List['ImportSubtask'] = []
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,32 +8,48 @@
|
|||||||
"title": "Cortex Rewire",
|
"title": "Cortex Rewire",
|
||||||
"description": "Refactor reasoning layer for improved performance",
|
"description": "Refactor reasoning layer for improved performance",
|
||||||
"status": "backlog",
|
"status": "backlog",
|
||||||
|
"estimated_minutes": 240,
|
||||||
|
"tags": ["coding", "backend", "refactoring"],
|
||||||
|
"flag_color": "red",
|
||||||
"subtasks": [
|
"subtasks": [
|
||||||
{
|
{
|
||||||
"title": "Reflection → fix backend argument bug",
|
"title": "Reflection → fix backend argument bug",
|
||||||
"status": "in_progress",
|
"status": "in_progress",
|
||||||
|
"estimated_minutes": 90,
|
||||||
|
"tags": ["coding", "bug-fix"],
|
||||||
|
"flag_color": "orange",
|
||||||
"subtasks": [
|
"subtasks": [
|
||||||
{
|
{
|
||||||
"title": "Normalize LLM backend arg in reflection calls",
|
"title": "Normalize LLM backend arg in reflection calls",
|
||||||
"status": "in_progress"
|
"status": "in_progress",
|
||||||
|
"estimated_minutes": 45,
|
||||||
|
"tags": ["coding"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "Add unit tests for reflection module",
|
"title": "Add unit tests for reflection module",
|
||||||
"status": "backlog"
|
"status": "backlog",
|
||||||
|
"estimated_minutes": 60,
|
||||||
|
"tags": ["testing", "coding"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "Reasoning parser cleanup",
|
"title": "Reasoning parser cleanup",
|
||||||
"status": "backlog",
|
"status": "backlog",
|
||||||
|
"estimated_minutes": 120,
|
||||||
|
"tags": ["coding", "cleanup"],
|
||||||
"subtasks": [
|
"subtasks": [
|
||||||
{
|
{
|
||||||
"title": "Remove deprecated parse methods",
|
"title": "Remove deprecated parse methods",
|
||||||
"status": "backlog"
|
"status": "backlog",
|
||||||
|
"estimated_minutes": 30,
|
||||||
|
"tags": ["coding"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "Optimize regex patterns",
|
"title": "Optimize regex patterns",
|
||||||
"status": "backlog"
|
"status": "backlog",
|
||||||
|
"estimated_minutes": 45,
|
||||||
|
"tags": ["coding", "performance"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -43,32 +59,47 @@
|
|||||||
"title": "Frontend Overhaul",
|
"title": "Frontend Overhaul",
|
||||||
"description": "Modernize the UI with new component library",
|
"description": "Modernize the UI with new component library",
|
||||||
"status": "backlog",
|
"status": "backlog",
|
||||||
|
"estimated_minutes": 480,
|
||||||
|
"tags": ["frontend", "ui", "coding"],
|
||||||
|
"flag_color": "blue",
|
||||||
"subtasks": [
|
"subtasks": [
|
||||||
{
|
{
|
||||||
"title": "Migrate to Tailwind CSS",
|
"title": "Migrate to Tailwind CSS",
|
||||||
"status": "backlog"
|
"status": "backlog",
|
||||||
|
"estimated_minutes": 180,
|
||||||
|
"tags": ["frontend", "styling"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "Build new component library",
|
"title": "Build new component library",
|
||||||
"status": "backlog",
|
"status": "backlog",
|
||||||
|
"estimated_minutes": 360,
|
||||||
|
"tags": ["frontend", "components"],
|
||||||
"subtasks": [
|
"subtasks": [
|
||||||
{
|
{
|
||||||
"title": "Button components",
|
"title": "Button components",
|
||||||
"status": "backlog"
|
"status": "backlog",
|
||||||
|
"estimated_minutes": 60,
|
||||||
|
"tags": ["frontend", "components"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "Form components",
|
"title": "Form components",
|
||||||
"status": "backlog"
|
"status": "backlog",
|
||||||
|
"estimated_minutes": 120,
|
||||||
|
"tags": ["frontend", "components"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "Modal components",
|
"title": "Modal components",
|
||||||
"status": "backlog"
|
"status": "backlog",
|
||||||
|
"estimated_minutes": 90,
|
||||||
|
"tags": ["frontend", "components"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "Implement dark mode toggle",
|
"title": "Implement dark mode toggle",
|
||||||
"status": "backlog"
|
"status": "backlog",
|
||||||
|
"estimated_minutes": 45,
|
||||||
|
"tags": ["frontend", "ui"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -76,36 +107,54 @@
|
|||||||
"title": "API v2 Implementation",
|
"title": "API v2 Implementation",
|
||||||
"status": "blocked",
|
"status": "blocked",
|
||||||
"description": "Blocked on database migration completion",
|
"description": "Blocked on database migration completion",
|
||||||
|
"estimated_minutes": 600,
|
||||||
|
"tags": ["backend", "api", "coding"],
|
||||||
|
"flag_color": "yellow",
|
||||||
"subtasks": [
|
"subtasks": [
|
||||||
{
|
{
|
||||||
"title": "Design new REST endpoints",
|
"title": "Design new REST endpoints",
|
||||||
"status": "done"
|
"status": "done",
|
||||||
|
"estimated_minutes": 120,
|
||||||
|
"tags": ["design", "api"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "Implement GraphQL layer",
|
"title": "Implement GraphQL layer",
|
||||||
"status": "blocked"
|
"status": "blocked",
|
||||||
|
"estimated_minutes": 300,
|
||||||
|
"tags": ["backend", "graphql", "coding"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "Add rate limiting",
|
"title": "Add rate limiting",
|
||||||
"status": "backlog"
|
"status": "backlog",
|
||||||
|
"estimated_minutes": 90,
|
||||||
|
"tags": ["backend", "security"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "Documentation Sprint",
|
"title": "Documentation Sprint",
|
||||||
"status": "done",
|
"status": "done",
|
||||||
|
"estimated_minutes": 180,
|
||||||
|
"tags": ["documentation", "writing"],
|
||||||
|
"flag_color": "green",
|
||||||
"subtasks": [
|
"subtasks": [
|
||||||
{
|
{
|
||||||
"title": "API documentation",
|
"title": "API documentation",
|
||||||
"status": "done"
|
"status": "done",
|
||||||
|
"estimated_minutes": 60,
|
||||||
|
"tags": ["documentation"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "User guide",
|
"title": "User guide",
|
||||||
"status": "done"
|
"status": "done",
|
||||||
|
"estimated_minutes": 90,
|
||||||
|
"tags": ["documentation", "tutorial"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "Developer setup guide",
|
"title": "Developer setup guide",
|
||||||
"status": "done"
|
"status": "done",
|
||||||
|
"estimated_minutes": 30,
|
||||||
|
"tags": ["documentation"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,15 @@ function App() {
|
|||||||
<div className="min-h-screen bg-cyber-dark">
|
<div className="min-h-screen bg-cyber-dark">
|
||||||
<header className="border-b border-cyber-orange/30 bg-cyber-darkest">
|
<header className="border-b border-cyber-orange/30 bg-cyber-darkest">
|
||||||
<div className="container mx-auto px-4 py-4">
|
<div className="container mx-auto px-4 py-4">
|
||||||
<h1 className="text-2xl font-bold text-cyber-orange">
|
<div className="flex justify-between items-center">
|
||||||
TESSERACT
|
<div>
|
||||||
<span className="ml-3 text-sm text-gray-500">Task Decomposition Engine</span>
|
<h1 className="text-2xl font-bold text-cyber-orange">
|
||||||
</h1>
|
TESSERACT
|
||||||
|
<span className="ml-3 text-sm text-gray-500">Task Decomposition Engine</span>
|
||||||
|
<span className="ml-2 text-xs text-gray-600">v0.1.3</span>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Plus, Edit2, Trash2, Check, X, ChevronDown, ChevronRight } from 'lucide-react'
|
import { Plus, Edit2, Trash2, Check, X } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
getProjectTasks,
|
getProjectTasks,
|
||||||
createTask,
|
createTask,
|
||||||
@@ -17,10 +17,11 @@ const STATUSES = [
|
|||||||
function TaskCard({ task, allTasks, onUpdate, onDragStart }) {
|
function TaskCard({ task, allTasks, onUpdate, onDragStart }) {
|
||||||
const [isEditing, setIsEditing] = useState(false)
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
const [editTitle, setEditTitle] = useState(task.title)
|
const [editTitle, setEditTitle] = useState(task.title)
|
||||||
const [showSubtasks, setShowSubtasks] = useState(false)
|
|
||||||
|
|
||||||
const subtasks = allTasks.filter(t => t.parent_task_id === task.id)
|
// Find parent task if this is a subtask
|
||||||
const hasSubtasks = subtasks.length > 0
|
const parentTask = task.parent_task_id
|
||||||
|
? allTasks.find(t => t.id === task.parent_task_id)
|
||||||
|
: null
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -75,8 +76,15 @@ function TaskCard({ task, allTasks, onUpdate, onDragStart }) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="flex justify-between items-start mb-2">
|
<div className="flex justify-between items-start">
|
||||||
<span className="text-gray-200 text-sm flex-1">{task.title}</span>
|
<div className="flex-1">
|
||||||
|
<div className="text-gray-200 text-sm">{task.title}</div>
|
||||||
|
{parentTask && (
|
||||||
|
<div className="text-xs text-gray-500 mt-1">
|
||||||
|
↳ subtask of: <span className="text-cyber-orange">{parentTask.title}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsEditing(true)}
|
onClick={() => setIsEditing(true)}
|
||||||
@@ -92,27 +100,6 @@ function TaskCard({ task, allTasks, onUpdate, onDragStart }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasSubtasks && (
|
|
||||||
<div className="mt-2 pt-2 border-t border-cyber-orange/20">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowSubtasks(!showSubtasks)}
|
|
||||||
className="flex items-center gap-1 text-xs text-cyber-orange hover:text-cyber-orange-bright"
|
|
||||||
>
|
|
||||||
{showSubtasks ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
|
||||||
{subtasks.length} subtask{subtasks.length !== 1 ? 's' : ''}
|
|
||||||
</button>
|
|
||||||
{showSubtasks && (
|
|
||||||
<div className="mt-2 pl-3 space-y-1">
|
|
||||||
{subtasks.map(subtask => (
|
|
||||||
<div key={subtask.id} className="text-xs text-gray-400">
|
|
||||||
• {subtask.title}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -255,9 +242,6 @@ function KanbanView({ projectId }) {
|
|||||||
return <div className="text-center text-red-400 py-12">{error}</div>
|
return <div className="text-center text-red-400 py-12">{error}</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only show root-level tasks in Kanban (tasks without parents)
|
|
||||||
const rootTasks = allTasks.filter(t => !t.parent_task_id)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-xl font-semibold text-gray-300 mb-4">Kanban Board</h3>
|
<h3 className="text-xl font-semibold text-gray-300 mb-4">Kanban Board</h3>
|
||||||
@@ -267,7 +251,7 @@ function KanbanView({ projectId }) {
|
|||||||
<KanbanColumn
|
<KanbanColumn
|
||||||
key={status.key}
|
key={status.key}
|
||||||
status={status}
|
status={status}
|
||||||
tasks={rootTasks.filter(t => t.status === status.key)}
|
tasks={allTasks.filter(t => t.status === status.key)}
|
||||||
allTasks={allTasks}
|
allTasks={allTasks}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
onUpdate={loadTasks}
|
onUpdate={loadTasks}
|
||||||
@@ -277,7 +261,7 @@ function KanbanView({ projectId }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{rootTasks.length === 0 && (
|
{allTasks.length === 0 && (
|
||||||
<div className="text-center py-16 text-gray-500">
|
<div className="text-center py-16 text-gray-500">
|
||||||
<p className="text-lg mb-2">No tasks yet</p>
|
<p className="text-lg mb-2">No tasks yet</p>
|
||||||
<p className="text-sm">Add tasks using the + button in any column</p>
|
<p className="text-sm">Add tasks using the + button in any column</p>
|
||||||
|
|||||||
@@ -85,9 +85,8 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
|
|||||||
return (
|
return (
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
<div
|
<div
|
||||||
className={`flex items-center gap-2 p-3 bg-cyber-darkest border border-cyber-orange/20 rounded hover:border-cyber-orange/40 transition-all group ${
|
style={{ marginLeft: `${level * 1.5}rem` }}
|
||||||
level > 0 ? 'ml-6' : ''
|
className="flex items-center gap-2 p-3 bg-cyber-darkest border border-cyber-orange/20 rounded hover:border-cyber-orange/40 transition-all group"
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{/* Expand/Collapse */}
|
{/* Expand/Collapse */}
|
||||||
{hasSubtasks && (
|
{hasSubtasks && (
|
||||||
@@ -176,7 +175,7 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
|
|||||||
|
|
||||||
{/* Add Subtask Form */}
|
{/* Add Subtask Form */}
|
||||||
{showAddSubtask && (
|
{showAddSubtask && (
|
||||||
<div className={`mt-2 ${level > 0 ? 'ml-6' : ''}`}>
|
<div style={{ marginLeft: `${level * 1.5}rem` }} className="mt-2">
|
||||||
<form onSubmit={handleAddSubtask} className="flex gap-2">
|
<form onSubmit={handleAddSubtask} className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|||||||
@@ -56,3 +56,12 @@ export const importJSON = (data) => fetchAPI('/import-json', {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Search
|
||||||
|
export const searchTasks = (query, projectIds = null) => {
|
||||||
|
const params = new URLSearchParams({ query });
|
||||||
|
if (projectIds && projectIds.length > 0) {
|
||||||
|
params.append('project_ids', projectIds.join(','));
|
||||||
|
}
|
||||||
|
return fetchAPI(`/search?${params.toString()}`);
|
||||||
|
};
|
||||||
|
|||||||
17
frontend/src/utils/format.js
Normal file
17
frontend/src/utils/format.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
// Format minutes into display string
|
||||||
|
export function formatTime(minutes) {
|
||||||
|
if (!minutes || minutes === 0) return null;
|
||||||
|
|
||||||
|
if (minutes < 60) {
|
||||||
|
return `${minutes}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hours = minutes / 60;
|
||||||
|
return `${hours.toFixed(1)}h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format tags as comma-separated string
|
||||||
|
export function formatTags(tags) {
|
||||||
|
if (!tags || tags.length === 0) return null;
|
||||||
|
return tags.join(', ');
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user