Refactor tasks to label workflow and simplify board layout

This commit is contained in:
OpenClaw Bot 2026-02-19 22:57:10 -06:00
parent 79bffbf0b8
commit 957907c87a
3 changed files with 390 additions and 264 deletions

View File

@ -50,7 +50,8 @@
],
"tags": [
"ui",
"rewrite"
"rewrite",
"Web Projects"
]
},
{
@ -64,7 +65,8 @@
"comments": [],
"tags": [
"ios",
"social"
"social",
"OpenClaw iOS"
],
"createdAt": "2026-02-18T17:01:23.109Z",
"updatedAt": "2026-02-20T02:28:23.700Z"
@ -98,7 +100,8 @@
"gitea",
"git",
"automation",
"infrastructure"
"infrastructure",
"Web Projects"
]
},
{
@ -125,7 +128,8 @@
"ux",
"redesign",
"dashboard",
"monitoring"
"monitoring",
"Web Projects"
]
},
{
@ -157,7 +161,8 @@
"blog",
"ui",
"markdown",
"links"
"links",
"Web Projects"
]
},
{
@ -184,7 +189,8 @@
"cron",
"bug",
"infrastructure",
"urgent"
"urgent",
"Web Projects"
]
},
{
@ -210,7 +216,8 @@
"debugging",
"research",
"infrastructure",
"root-cause"
"root-cause",
"Web Projects"
]
},
{
@ -242,7 +249,8 @@
"ui",
"sync",
"localstorage",
"real-time"
"real-time",
"Web Projects"
]
},
{
@ -268,7 +276,8 @@
"ui",
"kanban",
"feature",
"priority"
"priority",
"Web Projects"
]
},
{
@ -301,7 +310,8 @@
"screenshot",
"macos",
"openclaw",
"investigation"
"investigation",
"Web Projects"
]
},
{
@ -366,7 +376,8 @@
"blog",
"ui",
"markdown",
"frontend"
"frontend",
"Web Projects"
]
},
{
@ -400,7 +411,8 @@
"podcast",
"audio",
"digest",
"accessibility"
"accessibility",
"Web Projects"
]
},
{
@ -444,7 +456,8 @@
"backup",
"infrastructure",
"data-persistence",
"automation"
"automation",
"Web Projects"
]
},
{
@ -453,7 +466,10 @@
"title": "Add Sprint functionality to Gantt Board",
"projectId": "2",
"sprintId": "sprint-1",
"updatedAt": "2026-02-20T01:52:57.259Z"
"updatedAt": "2026-02-20T01:52:57.259Z",
"tags": [
"Web Projects"
]
}
],
"lastUpdated": 1771556223745,

View File

@ -3,6 +3,8 @@ import { readFileSync, writeFileSync, existsSync } from "fs";
import { join } from "path";
const DATA_FILE = join(process.cwd(), "data", "tasks.json");
console.log('>>> API ROUTE: module loaded, process.cwd():', process.cwd());
console.log('>>> API ROUTE: DATA_FILE:', DATA_FILE);
interface Task {
id: string;
@ -58,13 +60,20 @@ const defaultData: DataStore = {
};
function getData(): DataStore {
console.log('>>> getData: checking file:', DATA_FILE);
console.log('>>> getData: exists?', existsSync(DATA_FILE));
if (!existsSync(DATA_FILE)) {
console.log('>>> getData: file not found, returning defaultData');
return defaultData;
}
try {
const data = readFileSync(DATA_FILE, "utf-8");
return JSON.parse(data);
} catch {
const rawData = readFileSync(DATA_FILE, "utf-8");
console.log('>>> getData: read file, length:', rawData.length);
const data = JSON.parse(rawData);
console.log('>>> getData: parsed data has', data.tasks?.length, 'tasks');
return data;
} catch (err) {
console.error('>>> getData: error reading/parsing file:', err);
return defaultData;
}
}
@ -80,7 +89,11 @@ function saveData(data: DataStore) {
// GET - fetch all tasks, projects, and sprints
export async function GET() {
console.log('>>> API GET: fetching data');
console.log('>>> API GET: DATA_FILE path:', DATA_FILE);
const data = getData();
console.log('>>> API GET: returning data with', data.tasks?.length, 'tasks,', data.projects?.length, 'projects,', data.sprints?.length, 'sprints');
console.log('>>> API GET: lastUpdated:', data.lastUpdated);
return NextResponse.json(data);
}

View File

@ -1,15 +1,15 @@
"use client"
import { useState, useEffect } from "react"
import { useState, useEffect, useMemo } from "react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Card, CardContent } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
import { Textarea } from "@/components/ui/textarea"
import { Label } from "@/components/ui/label"
import { useTaskStore, Task, TaskType, TaskStatus, Priority, Project } from "@/stores/useTaskStore"
import { useTaskStore, Task, TaskType, TaskStatus, Priority } from "@/stores/useTaskStore"
import { BacklogView } from "@/components/BacklogView"
import { Plus, MessageSquare, Calendar, Tag, Trash2, Edit2, X, Check, MoreHorizontal, LayoutGrid, ListTodo } from "lucide-react"
import { Plus, MessageSquare, Calendar, Trash2, Edit2, X, LayoutGrid, ListTodo } from "lucide-react"
const typeColors: Record<TaskType, string> = {
idea: "bg-purple-500",
@ -63,23 +63,16 @@ export default function Home() {
sprints,
selectedProjectId,
selectedTaskId,
selectedSprintId,
selectProject,
addProject,
deleteProject,
addTask,
updateTask,
deleteTask,
selectTask,
addComment,
deleteComment,
getTasksByProject,
syncFromServer,
isLoading,
} = useTaskStore()
const [newProjectName, setNewProjectName] = useState("")
const [showNewProject, setShowNewProject] = useState(false)
const [newTaskOpen, setNewTaskOpen] = useState(false)
const [newTask, setNewTask] = useState<Partial<Task>>({
title: "",
@ -90,17 +83,33 @@ export default function Home() {
tags: [],
})
const [newComment, setNewComment] = useState("")
const [editingTask, setEditingTask] = useState<Task | null>(null)
const [viewMode, setViewMode] = useState<'kanban' | 'backlog'>('kanban')
const [editedTask, setEditedTask] = useState<Task | null>(null)
const [newTaskLabelInput, setNewTaskLabelInput] = useState("")
const [editedTaskLabelInput, setEditedTaskLabelInput] = useState("")
const getTags = (taskLike: { tags?: unknown }) => {
if (!Array.isArray(taskLike.tags)) return [] as string[]
return taskLike.tags.filter((tag): tag is string => typeof tag === "string" && tag.trim().length > 0)
}
const labelUsage = useMemo(() => {
const counts = new Map<string, number>()
tasks.forEach((task) => {
getTags(task).forEach((label) => {
counts.set(label, (counts.get(label) || 0) + 1)
})
})
return Array.from(counts.entries()).sort((a, b) => b[1] - a[1])
}, [tasks])
const allLabels = useMemo(() => labelUsage.map(([label]) => label), [labelUsage])
// Sync from server on mount
useEffect(() => {
console.log('>>> PAGE: useEffect for syncFromServer running')
console.log('>>> PAGE: tasks count before sync:', tasks.length)
syncFromServer().then(() => {
console.log('>>> PAGE: syncFromServer completed')
console.log('>>> PAGE: tasks count after sync:', tasks.length)
})
}, [syncFromServer])
@ -109,16 +118,16 @@ export default function Home() {
console.log('>>> PAGE: tasks changed, new count:', tasks.length)
}, [tasks])
const selectedProject = projects.find((p) => p.id === selectedProjectId)
const selectedTask = tasks.find((t) => t.id === selectedTaskId)
const editedTaskTags = editedTask ? getTags(editedTask) : []
// Set editedTask when selectedTask changes
useEffect(() => {
if (selectedTask) {
setEditedTask({ ...selectedTask })
// eslint-disable-next-line react-hooks/set-state-in-effect
setEditedTask({ ...selectedTask, tags: getTags(selectedTask) })
setEditedTaskLabelInput("")
}
}, [selectedTask])
const projectTasks = selectedProjectId ? getTasksByProject(selectedProjectId) : []
// Get current active sprint (across all projects)
const now = new Date()
@ -133,27 +142,38 @@ export default function Home() {
? tasks.filter((t) => t.sprintId === currentSprint.id)
: []
const handleAddProject = () => {
if (newProjectName.trim()) {
addProject(newProjectName.trim())
setNewProjectName("")
setShowNewProject(false)
}
const toLabel = (raw: string) => raw.trim().replace(/^#/, "")
const addUniqueLabel = (existing: string[], raw: string) => {
const nextLabel = toLabel(raw)
if (!nextLabel) return existing
const alreadyExists = existing.some((label) => label.toLowerCase() === nextLabel.toLowerCase())
return alreadyExists ? existing : [...existing, nextLabel]
}
const removeLabel = (existing: string[], labelToRemove: string) =>
existing.filter((label) => label.toLowerCase() !== labelToRemove.toLowerCase())
const handleAddTask = () => {
if (newTask.title?.trim()) {
// If a specific sprint is selected, use that sprint's project
const selectedSprint = newTask.sprintId ? sprints.find(s => s.id === newTask.sprintId) : null
const targetProjectId = selectedSprint?.projectId || selectedProjectId || '2'
const targetProjectId = selectedSprint?.projectId || selectedProjectId || projects[0]?.id || '2'
addTask({
...newTask,
const taskToCreate: Omit<Task, 'id' | 'createdAt' | 'updatedAt' | 'comments'> = {
title: newTask.title.trim(),
description: newTask.description?.trim() || undefined,
type: (newTask.type || "task") as TaskType,
priority: (newTask.priority || "medium") as Priority,
status: (newTask.status || "backlog") as TaskStatus,
tags: newTask.tags || [],
projectId: targetProjectId,
status: newTask.status || "backlog",
sprintId: newTask.sprintId || currentSprint?.id, // Use selected sprint or default to current
} as any)
sprintId: newTask.sprintId || currentSprint?.id,
}
addTask(taskToCreate)
setNewTask({ title: "", description: "", type: "task", priority: "medium", status: "backlog", tags: [], sprintId: undefined })
setNewTaskLabelInput("")
setNewTaskOpen(false)
}
}
@ -165,10 +185,6 @@ export default function Home() {
}
}
const handleUpdateTaskStatus = (taskId: string, newStatus: TaskStatus) => {
updateTask(taskId, { status: newStatus })
}
return (
<div className="min-h-screen bg-slate-950 text-slate-100">
{/* Header */}
@ -177,7 +193,7 @@ export default function Home() {
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl md:text-2xl font-bold bg-gradient-to-r from-blue-400 to-purple-400 bg-clip-text text-transparent">
OpenClaw Project Hub
OpenClaw Task Hub
</h1>
<p className="text-xs md:text-sm text-slate-400 mt-1">
Track ideas, tasks, bugs, and plans with threaded notes
@ -194,7 +210,7 @@ export default function Home() {
</span>
)}
<span className="hidden md:inline text-sm text-slate-400">
{tasks.length} tasks · {projects.length} projects
{tasks.length} tasks · {allLabels.length} labels
</span>
</div>
</div>
@ -202,68 +218,12 @@ export default function Home() {
</header>
<div className="max-w-[1800px] mx-auto px-4 py-4 md:py-6">
<div className="flex flex-col lg:flex-row gap-4 lg:gap-6">
{/* Sidebar - Sprint Info */}
<aside className="w-full lg:w-64 shrink-0">
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold text-slate-400 uppercase tracking-wider">
Current Sprint
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{currentSprint ? (
<>
<div>
<h3 className="font-medium text-white">{currentSprint.name}</h3>
<p className="text-sm text-slate-400">
{new Date(currentSprint.startDate).toLocaleDateString()} - {new Date(currentSprint.endDate).toLocaleDateString()}
</p>
</div>
<div className="flex items-center gap-4 text-sm">
<span className="text-slate-400">
<strong className="text-white">{sprintTasks.length}</strong> tasks
</span>
<span className="text-slate-400">
<strong className="text-white">{sprintTasks.filter(t => t.status === 'done').length}</strong> done
</span>
</div>
</>
) : (
<p className="text-sm text-slate-500">No active sprint</p>
)}
</CardContent>
</Card>
{/* Projects Quick View */}
<Card className="bg-slate-900 border-slate-800 mt-4">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold text-slate-400 uppercase tracking-wider">
Projects
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{projects.map((project) => (
<div key={project.id} className="flex items-center gap-2 text-sm">
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: project.color }}
/>
<span className="text-slate-400">{project.name}</span>
</div>
))}
</CardContent>
</Card>
</aside>
{/* Main Content */}
<main className="flex-1 min-w-0">
{selectedProject ? (
<>
<main className="min-w-0">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-4 md:mb-6">
<div>
<h2 className="text-lg md:text-xl font-semibold text-white">
{selectedProject.name}
{currentSprint ? currentSprint.name : "Work Board"}
</h2>
<p className="text-sm text-slate-400">
{sprintTasks.length} tasks · {sprintTasks.filter((t) => t.status === "done").length} done
@ -311,10 +271,14 @@ export default function Home() {
<div className="mb-4 p-3 bg-slate-800/50 border border-slate-700 rounded-lg">
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium text-white">Sprint 1</h3>
<p className="text-sm text-slate-400">Feb 16 - Feb 22, 2026</p>
<h3 className="font-medium text-white">{currentSprint?.name || "No Active Sprint"}</h3>
<p className="text-sm text-slate-400">
{currentSprint
? `${new Date(currentSprint.startDate).toLocaleDateString()} - ${new Date(currentSprint.endDate).toLocaleDateString()}`
: "Create or activate a sprint to group work"}
</p>
</div>
<Badge variant="default">Active</Badge>
{currentSprint && <Badge variant="default">Active</Badge>}
</div>
</div>
{/* Kanban Columns */}
@ -337,7 +301,7 @@ export default function Home() {
<div className="space-y-3">
{columnTasks.map((task) => {
const taskProject = projects.find((p) => p.id === task.projectId)
const taskTags = getTags(task)
return (
<Card
key={task.id}
@ -353,21 +317,12 @@ export default function Home() {
>
{typeLabels[task.type]}
</Badge>
{taskProject && (
<Badge
variant="outline"
className="text-xs text-white border-0"
style={{ backgroundColor: taskProject.color }}
>
{taskProject.name}
</Badge>
)}
</div>
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-1">
<button
onClick={(e) => {
e.stopPropagation()
setEditingTask(task)
selectTask(task.id)
}}
className="p-1 hover:bg-slate-800 rounded"
>
@ -412,9 +367,9 @@ export default function Home() {
)}
</div>
{task.tags.length > 0 && (
{taskTags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-3">
{task.tags.map((tag) => (
{taskTags.map((tag) => (
<span
key={tag}
className="text-xs px-2 py-0.5 bg-slate-800 text-slate-400 rounded"
@ -426,7 +381,8 @@ export default function Home() {
)}
</CardContent>
</Card>
)})}
)
})}
</div>
</div>
)
@ -434,15 +390,8 @@ export default function Home() {
</div>
</>
)}
</>
) : (
<div className="flex items-center justify-center h-96 text-slate-500">
<p>Select a project to view tasks</p>
</div>
)}
</main>
</div>
</div>
{/* New Task Dialog */}
<Dialog open={newTaskOpen} onOpenChange={setNewTaskOpen}>
@ -528,13 +477,83 @@ export default function Home() {
</div>
</div>
<div>
<Label>Tags (comma separated)</Label>
<Label>Labels</Label>
<div className="mt-1.5 space-y-2">
{newTask.tags && newTask.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{newTask.tags.map((label) => (
<Badge
key={label}
variant="secondary"
className="bg-slate-800 text-slate-300 gap-1"
>
{label}
<button
type="button"
onClick={() =>
setNewTask((prev) => ({ ...prev, tags: removeLabel(prev.tags || [], label) }))
}
className="text-slate-500 hover:text-slate-200"
>
<X className="w-3 h-3" />
</button>
</Badge>
))}
</div>
)}
<div className="flex gap-2">
<input
type="text"
onChange={(e) => setNewTask({ ...newTask, tags: e.target.value.split(",").map((t) => t.trim()).filter(Boolean) })}
className="w-full mt-1.5 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:border-blue-500"
placeholder="ios, backend, urgent"
list="new-task-label-suggestions"
value={newTaskLabelInput}
onChange={(e) => setNewTaskLabelInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault()
setNewTask((prev) => ({
...prev,
tags: addUniqueLabel(prev.tags || [], newTaskLabelInput),
}))
setNewTaskLabelInput("")
}
}}
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:border-blue-500"
placeholder="Type a label and press Enter"
/>
<Button
type="button"
variant="outline"
onClick={() => {
setNewTask((prev) => ({ ...prev, tags: addUniqueLabel(prev.tags || [], newTaskLabelInput) }))
setNewTaskLabelInput("")
}}
>
Add
</Button>
</div>
<datalist id="new-task-label-suggestions">
{allLabels.map((label) => (
<option key={label} value={label} />
))}
</datalist>
{allLabels.filter((label) => !(newTask.tags || []).some((tag) => tag.toLowerCase() === label.toLowerCase())).slice(0, 8).length > 0 && (
<div className="flex flex-wrap gap-2">
{allLabels
.filter((label) => !(newTask.tags || []).some((tag) => tag.toLowerCase() === label.toLowerCase()))
.slice(0, 8)
.map((label) => (
<button
key={label}
type="button"
onClick={() => setNewTask((prev) => ({ ...prev, tags: addUniqueLabel(prev.tags || [], label) }))}
className="text-xs px-2 py-1 rounded-md border border-slate-700 text-slate-300 hover:border-slate-500"
>
+ {label}
</button>
))}
</div>
)}
</div>
</div>
</div>
<DialogFooter>
@ -640,6 +659,84 @@ export default function Home() {
</select>
</div>
{/* Labels */}
<div>
<Label className="text-slate-400">Labels</Label>
<div className="mt-2 space-y-2">
{editedTaskTags.length > 0 && (
<div className="flex flex-wrap gap-2">
{editedTaskTags.map((label) => (
<Badge
key={label}
variant="secondary"
className="bg-slate-800 text-slate-300 gap-1"
>
{label}
<button
type="button"
onClick={() =>
setEditedTask({ ...editedTask, tags: removeLabel(editedTaskTags, label) })
}
className="text-slate-500 hover:text-slate-200"
>
<X className="w-3 h-3" />
</button>
</Badge>
))}
</div>
)}
<div className="flex gap-2">
<input
type="text"
list="edit-task-label-suggestions"
value={editedTaskLabelInput}
onChange={(e) => setEditedTaskLabelInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault()
setEditedTask({ ...editedTask, tags: addUniqueLabel(editedTaskTags, editedTaskLabelInput) })
setEditedTaskLabelInput("")
}
}}
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:border-blue-500"
placeholder="Type a label and press Enter"
/>
<Button
type="button"
variant="outline"
onClick={() => {
setEditedTask({ ...editedTask, tags: addUniqueLabel(editedTaskTags, editedTaskLabelInput) })
setEditedTaskLabelInput("")
}}
>
Add
</Button>
</div>
<datalist id="edit-task-label-suggestions">
{allLabels.map((label) => (
<option key={label} value={label} />
))}
</datalist>
{allLabels.filter((label) => !editedTaskTags.some((tag) => tag.toLowerCase() === label.toLowerCase())).slice(0, 8).length > 0 && (
<div className="flex flex-wrap gap-2">
{allLabels
.filter((label) => !editedTaskTags.some((tag) => tag.toLowerCase() === label.toLowerCase()))
.slice(0, 8)
.map((label) => (
<button
key={label}
type="button"
onClick={() => setEditedTask({ ...editedTask, tags: addUniqueLabel(editedTaskTags, label) })}
className="text-xs px-2 py-1 rounded-md border border-slate-700 text-slate-300 hover:border-slate-500"
>
+ {label}
</button>
))}
</div>
)}
</div>
</div>
{/* Comments Section */}
<div className="border-t border-slate-800 pt-6">
<h4 className="font-medium text-white mb-4 flex items-center gap-2">