From eaf01cf634e0ebcfbae6659320ad516783f97733 Mon Sep 17 00:00:00 2001 From: OpenClaw Bot Date: Thu, 19 Feb 2026 23:25:43 -0600 Subject: [PATCH] Finalize sprint/backlog status flow and document current behavior --- README.md | 120 ++++++-- data/tasks.json | 15 +- src/app/api/tasks/route.ts | 8 +- src/app/page.tsx | 481 +++++++++++++++++++++++++-------- src/components/BacklogView.tsx | 6 +- src/stores/useTaskStore.ts | 6 +- 6 files changed, 478 insertions(+), 158 deletions(-) diff --git a/README.md b/README.md index e215bc4..f6bea81 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,100 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# Gantt Board -## Getting Started +Task and sprint board built with Next.js + Zustand and file-backed API persistence. -First, run the development server: +## Current Product Behavior + +### Data model and status rules + +- Tasks use labels (`tags: string[]`) and can have multiple labels. +- There is no active `backlog` status in workflow logic. +- A task is considered in Backlog when `sprintId` is empty. +- Current status values: + - `open` + - `todo` + - `blocked` + - `in-progress` + - `review` + - `validate` + - `archived` + - `canceled` + - `done` +- New tasks default to `status: open`. + +### Labels + +- Project selection UI for tasks was removed in favor of labels. +- You can add/remove labels inline in: + - New Task modal + - Task Detail modal +- Label entry supports: + - Enter/comma to add + - Existing-label suggestions + - Quick-add chips + - Case-insensitive de-duplication + +### Backlog drag and drop + +Backlog view supports moving tasks between: +- Current sprint +- Other sprint sections +- Backlog + +Status behavior on drop: +- Drop into any sprint section: `status -> open` +- Drop into backlog section: `status -> open` +- `sprintId` is set/cleared based on destination + +Changes persist through store sync to `data/tasks.json`. + +### Kanban drag and drop + +Kanban supports drag by visible left handle on each task card. + +Columns: +- `To Do` contains statuses: `open`, `todo` +- `In Progress` contains statuses: `blocked`, `in-progress`, `review`, `validate` +- `Done` contains statuses: `archived`, `canceled`, `done` + +Drop behavior: +- Drop on column body: applies that column's default status + - `To Do` -> `open` + - `In Progress` -> `in-progress` + - `Done` -> `done` +- Drop on status chip target: applies exact status +- Drop on a task: adopts that task's exact status + +During drag, the active target column shows expanded status drop zones for clarity. + +### Layout + +- Left sidebar cards (Current Sprint and Labels quick view) were removed. +- Main board now uses full width for Kanban/Backlog views. + +## Persistence + +- Client state is managed with Zustand. +- Persistence is done via `/api/tasks`. +- API reads/writes `data/tasks.json` (single-file storage). + +## Run locally + +```bash +npm install +npm run dev +``` + +Open `http://localhost:3000`. + +## Scripts ```bash npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev +npm run build +npm run start +npm run lint ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +## Notes -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. - -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. - -## Learn More - -To learn more about Next.js, take a look at the following resources: - -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. - -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! - -## Deploy on Vercel - -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. - -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +- You may see a Next.js warning about multiple lockfiles and inferred workspace root. This is non-blocking for local dev. diff --git a/data/tasks.json b/data/tasks.json index 4430a6a..d4ac4b4 100644 --- a/data/tasks.json +++ b/data/tasks.json @@ -28,12 +28,12 @@ "title": "Redesign Gantt Board", "description": "Make it actually work with proper notes system", "type": "task", - "status": "in-progress", + "status": "archived", "priority": "high", "projectId": "2", "sprintId": "sprint-1", "createdAt": "2026-02-18T17:01:23.109Z", - "updatedAt": "2026-02-20T05:01:04.207Z", + "updatedAt": "2026-02-20T05:14:09.324Z", "comments": [ { "id": "c1", @@ -58,7 +58,7 @@ "id": "2", "title": "MoodWeave App Idea - UPDATED", "projectId": "1", - "status": "backlog", + "status": "open", "priority": "high", "type": "idea", "comments": [], @@ -384,7 +384,7 @@ "title": "Research TTS options for Daily Digest podcast", "description": "Research free text-to-speech (TTS) tools to convert the daily digest blog posts into an audio podcast format.", "type": "research", - "status": "backlog", + "status": "open", "priority": "medium", "projectId": "2", "sprintId": "sprint-1", @@ -465,13 +465,14 @@ "title": "Add Sprint functionality to Gantt Board", "projectId": "2", "sprintId": "sprint-1", - "updatedAt": "2026-02-20T01:52:57.259Z", + "updatedAt": "2026-02-20T05:24:24.353Z", "tags": [ "Web Projects" - ] + ], + "status": "todo" } ], - "lastUpdated": 1771563767290, + "lastUpdated": 1771565064468, "sprints": [ { "name": "Sprint 1", diff --git a/src/app/api/tasks/route.ts b/src/app/api/tasks/route.ts index e98f4e4..3569ff1 100644 --- a/src/app/api/tasks/route.ts +++ b/src/app/api/tasks/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server"; -import { readFileSync, writeFileSync, existsSync } from "fs"; +import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs"; import { join } from "path"; const DATA_FILE = join(process.cwd(), "data", "tasks.json"); @@ -11,7 +11,7 @@ interface Task { title: string; description?: string; type: 'idea' | 'task' | 'bug' | 'research' | 'plan'; - status: 'open' | 'backlog' | 'blocked' | 'in-progress' | 'review' | 'validate' | 'archived' | 'canceled' | 'done'; + status: 'open' | 'todo' | 'blocked' | 'in-progress' | 'review' | 'validate' | 'archived' | 'canceled' | 'done'; priority: 'low' | 'medium' | 'high' | 'urgent'; projectId: string; sprintId?: string; @@ -81,7 +81,7 @@ function getData(): DataStore { function saveData(data: DataStore) { const dir = join(process.cwd(), "data"); if (!existsSync(dir)) { - require("fs").mkdirSync(dir, { recursive: true }); + mkdirSync(dir, { recursive: true }); } data.lastUpdated = Date.now(); writeFileSync(DATA_FILE, JSON.stringify(data, null, 2)); @@ -160,7 +160,7 @@ export async function DELETE(request: Request) { data.tasks = data.tasks.filter((t) => t.id !== id); saveData(data); return NextResponse.json({ success: true }); - } catch (error) { + } catch { return NextResponse.json({ error: "Failed to delete" }, { status: 500 }); } } diff --git a/src/app/page.tsx b/src/app/page.tsx index 8bcb1e6..af34885 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,6 +1,20 @@ "use client" -import { useState, useEffect, useMemo } from "react" +import { useState, useEffect, useMemo, type ReactNode } from "react" +import { + DndContext, + DragEndEvent, + DragOverlay, + DragOverEvent, + DragStartEvent, + PointerSensor, + useDraggable, + useDroppable, + useSensor, + useSensors, + closestCorners, +} from "@dnd-kit/core" +import { CSS } from "@dnd-kit/utilities" import { Button } from "@/components/ui/button" import { Card, CardContent } from "@/components/ui/card" import { Badge } from "@/components/ui/badge" @@ -9,7 +23,7 @@ import { Textarea } from "@/components/ui/textarea" import { Label } from "@/components/ui/label" import { useTaskStore, Task, TaskType, TaskStatus, Priority } from "@/stores/useTaskStore" import { BacklogView } from "@/components/BacklogView" -import { Plus, MessageSquare, Calendar, Trash2, Edit2, X, LayoutGrid, ListTodo } from "lucide-react" +import { Plus, MessageSquare, Calendar, Trash2, X, LayoutGrid, ListTodo, GripVertical } from "lucide-react" const typeColors: Record = { idea: "bg-purple-500", @@ -34,14 +48,14 @@ const priorityColors: Record = { urgent: "text-red-400", } -const allStatuses: TaskStatus[] = ["open", "backlog", "blocked", "in-progress", "review", "validate", "archived", "canceled", "done"] +const allStatuses: TaskStatus[] = ["open", "todo", "blocked", "in-progress", "review", "validate", "archived", "canceled", "done"] // Sprint board columns mapped to workflow statuses const sprintColumns: { key: string; label: string; statuses: TaskStatus[] }[] = [ { key: "todo", label: "To Do", - statuses: ["open", "backlog"] + statuses: ["open", "todo"] }, { key: "inprogress", @@ -55,6 +69,215 @@ const sprintColumns: { key: string; label: string; statuses: TaskStatus[] }[] = }, ] +const sprintColumnDropStatus: Record = { + todo: "open", + inprogress: "in-progress", + done: "done", +} + +const formatStatusLabel = (status: TaskStatus) => + status === "todo" + ? "To Do" + : + status + .split("-") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" ") + +function KanbanStatusDropTarget({ + status, + count, + expanded = false, +}: { + status: TaskStatus + count: number + expanded?: boolean +}) { + const { isOver, setNodeRef } = useDroppable({ id: `kanban-status-${status}` }) + + return ( +
+ {formatStatusLabel(status)} ({count}) +
+ ) +} + +function KanbanDropColumn({ + id, + label, + count, + statusTargets, + isDragging, + isActiveDropColumn, + children, +}: { + id: string + label: string + count: number + statusTargets: Array<{ status: TaskStatus; count: number }> + isDragging: boolean + isActiveDropColumn: boolean + children: ReactNode +}) { + const { isOver, setNodeRef } = useDroppable({ id }) + + return ( +
+
+

{label}

+ + {count} + +
+
+ {statusTargets.map((target) => ( + + ))} +
+
+ {isDragging && isActiveDropColumn ? ( +
+

Drop into an exact status:

+ {statusTargets.map((target) => ( + + ))} +
+ ) : ( + children + )} +
+
+ ) +} + +function KanbanTaskCard({ + task, + taskTags, + onOpen, + onDelete, +}: { + task: Task + taskTags: string[] + onOpen: () => void + onDelete: () => void +}) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useDraggable({ + id: task.id, + }) + + const style = { + transform: CSS.Translate.toString(transform), + transition, + opacity: isDragging ? 0.6 : 1, + } + + return ( + + +
+
+ + + {typeLabels[task.type]} + +
+ +
+ +

{task.title}

+ {task.description && ( +

+ {task.description} +

+ )} + +
+
+ + {task.priority} + + {task.comments && task.comments.length > 0 && ( + + + {task.comments.length} + + )} +
+ {task.dueDate && ( + + + {new Date(task.dueDate).toLocaleDateString()} + + )} +
+ + {taskTags.length > 0 && ( +
+ {taskTags.map((tag) => ( + + {tag} + + ))} +
+ )} +
+
+ ) +} + export default function Home() { console.log('>>> PAGE: Component rendering') const { @@ -79,7 +302,7 @@ export default function Home() { description: "", type: "task", priority: "medium", - status: "backlog", + status: "open", tags: [], }) const [newComment, setNewComment] = useState("") @@ -87,6 +310,8 @@ export default function Home() { const [editedTask, setEditedTask] = useState(null) const [newTaskLabelInput, setNewTaskLabelInput] = useState("") const [editedTaskLabelInput, setEditedTaskLabelInput] = useState("") + const [activeKanbanTaskId, setActiveKanbanTaskId] = useState(null) + const [dragOverKanbanColumnKey, setDragOverKanbanColumnKey] = useState(null) const getTags = (taskLike: { tags?: unknown }) => { if (!Array.isArray(taskLike.tags)) return [] as string[] @@ -141,6 +366,9 @@ export default function Home() { const sprintTasks = currentSprint ? tasks.filter((t) => t.sprintId === currentSprint.id) : [] + const activeKanbanTask = activeKanbanTaskId + ? sprintTasks.find((task) => task.id === activeKanbanTaskId) + : null const toLabel = (raw: string) => raw.trim().replace(/^#/, "") @@ -154,6 +382,87 @@ export default function Home() { const removeLabel = (existing: string[], labelToRemove: string) => existing.filter((label) => label.toLowerCase() !== labelToRemove.toLowerCase()) + const getColumnKeyForStatus = (status: TaskStatus) => + sprintColumns.find((column) => column.statuses.includes(status))?.key + + const kanbanSensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }) + ) + + const handleKanbanDragStart = (event: DragStartEvent) => { + setActiveKanbanTaskId(String(event.active.id)) + } + + const resolveKanbanColumnKey = (overId: string): string | null => { + if (overId.startsWith("kanban-col-")) { + return overId.replace("kanban-col-", "") + } + if (overId.startsWith("kanban-status-")) { + const status = overId.replace("kanban-status-", "") as TaskStatus + return getColumnKeyForStatus(status) || null + } + const overTask = sprintTasks.find((task) => task.id === overId) + if (!overTask) return null + return getColumnKeyForStatus(overTask.status) || null + } + + const handleKanbanDragOver = (event: DragOverEvent) => { + if (!event.over) { + setDragOverKanbanColumnKey(null) + return + } + setDragOverKanbanColumnKey(resolveKanbanColumnKey(String(event.over.id))) + } + + const handleKanbanDragEnd = (event: DragEndEvent) => { + const { active, over } = event + setActiveKanbanTaskId(null) + setDragOverKanbanColumnKey(null) + + if (!over) return + + const taskId = String(active.id) + const draggedTask = sprintTasks.find((task) => task.id === taskId) + if (!draggedTask) return + const destination = String(over.id) + + const overTask = sprintTasks.find((task) => task.id === destination) + if (overTask) { + if (overTask.status !== draggedTask.status) { + updateTask(taskId, { status: overTask.status }) + } + return + } + + if (destination.startsWith("kanban-status-")) { + const exactStatus = destination.replace("kanban-status-", "") as TaskStatus + if (exactStatus !== draggedTask.status) { + updateTask(taskId, { status: exactStatus }) + } + return + } + + if (!destination.startsWith("kanban-col-")) return + + const destinationColumnKey = destination.replace("kanban-col-", "") + const sourceColumnKey = getColumnKeyForStatus(draggedTask.status) + if (sourceColumnKey === destinationColumnKey) return + + const newStatus = sprintColumnDropStatus[destinationColumnKey] + if (!newStatus) return + + updateTask(taskId, { status: newStatus }) + } + + const handleKanbanDragCancel = () => { + setActiveKanbanTaskId(null) + setDragOverKanbanColumnKey(null) + } + const handleAddTask = () => { if (newTask.title?.trim()) { // If a specific sprint is selected, use that sprint's project @@ -165,14 +474,14 @@ export default function Home() { description: newTask.description?.trim() || undefined, type: (newTask.type || "task") as TaskType, priority: (newTask.priority || "medium") as Priority, - status: (newTask.status || "backlog") as TaskStatus, + status: (newTask.status || "open") as TaskStatus, tags: newTask.tags || [], projectId: targetProjectId, sprintId: newTask.sprintId || currentSprint?.id, } addTask(taskToCreate) - setNewTask({ title: "", description: "", type: "task", priority: "medium", status: "backlog", tags: [], sprintId: undefined }) + setNewTask({ title: "", description: "", type: "task", priority: "medium", status: "open", tags: [], sprintId: undefined }) setNewTaskLabelInput("") setNewTaskOpen(false) } @@ -282,112 +591,58 @@ export default function Home() { {/* Kanban Columns */} -
- {sprintColumns.map((column) => { - // Filter tasks by column statuses - const columnTasks = sprintTasks.filter((t) => - column.statuses.includes(t.status) - ) - return ( -
-
-

- {column.label} -

- - {columnTasks.length} - -
- -
- {columnTasks.map((task) => { - const taskTags = getTags(task) - return ( - selectTask(task.id)} - > - -
-
- - {typeLabels[task.type]} - -
-
- - -
-
- -

{task.title}

- {task.description && ( -

- {task.description} -

- )} - -
-
- - {task.priority} - - {task.comments && task.comments.length > 0 && ( - - - {task.comments.length} - - )} -
- {task.dueDate && ( - - - {new Date(task.dueDate).toLocaleDateString()} - - )} -
- - {taskTags.length > 0 && ( -
- {taskTags.map((tag) => ( - - {tag} - - ))} -
- )} -
-
- ) - })} -
-
- ) - })} -
+ +
+ {sprintColumns.map((column) => { + const columnTasks = sprintTasks.filter((t) => + column.statuses.includes(t.status) + ) + return ( + ({ + status, + count: sprintTasks.filter((task) => task.status === status).length, + }))} + isDragging={!!activeKanbanTaskId} + isActiveDropColumn={dragOverKanbanColumnKey === column.key} + > + {columnTasks.map((task) => ( + selectTask(task.id)} + onDelete={() => deleteTask(task.id)} + /> + ))} + + ) + })} +
+ + {activeKanbanTask ? ( + + +
+ + {activeKanbanTask.title} +
+
+
+ ) : null} +
+
)} diff --git a/src/components/BacklogView.tsx b/src/components/BacklogView.tsx index 4b73c64..d34773a 100644 --- a/src/components/BacklogView.tsx +++ b/src/components/BacklogView.tsx @@ -270,12 +270,12 @@ export function BacklogView() { // If dropped over a section header, move task to that section's sprint if (destinationId === "backlog") { - updateTask(taskId, { sprintId: undefined }) + updateTask(taskId, { sprintId: undefined, status: "open" }) } else if (destinationId === "current" && currentSprint) { - updateTask(taskId, { sprintId: currentSprint.id }) + updateTask(taskId, { sprintId: currentSprint.id, status: "open" }) } else if (destinationId.startsWith("sprint-")) { const sprintId = destinationId.replace("sprint-", "") - updateTask(taskId, { sprintId }) + updateTask(taskId, { sprintId, status: "open" }) } } diff --git a/src/stores/useTaskStore.ts b/src/stores/useTaskStore.ts index 37d81ee..2e45a70 100644 --- a/src/stores/useTaskStore.ts +++ b/src/stores/useTaskStore.ts @@ -2,7 +2,7 @@ import { create } from 'zustand' import { persist } from 'zustand/middleware' export type TaskType = 'idea' | 'task' | 'bug' | 'research' | 'plan' -export type TaskStatus = 'open' | 'backlog' | 'blocked' | 'in-progress' | 'review' | 'validate' | 'archived' | 'canceled' | 'done' +export type TaskStatus = 'open' | 'todo' | 'blocked' | 'in-progress' | 'review' | 'validate' | 'archived' | 'canceled' | 'done' export type Priority = 'low' | 'medium' | 'high' | 'urgent' export type SprintStatus = 'planning' | 'active' | 'completed' @@ -137,7 +137,7 @@ const defaultTasks: Task[] = [ title: 'MoodWeave App Idea', description: 'Social mood tracking with woven visualizations', type: 'idea', - status: 'backlog', + status: 'open', priority: 'medium', projectId: '1', sprintId: 'sprint-1', @@ -359,7 +359,7 @@ const defaultTasks: Task[] = [ title: 'Research TTS options for Daily Digest podcast', description: 'Research free text-to-speech (TTS) tools to convert the daily digest blog posts into an audio podcast format. Matt wants to listen to the digest during his morning dog walks with Tully and Remy. Look into: free TTS APIs (ElevenLabs free tier, Google TTS, AWS Polly), open-source solutions (Piper, Coqui TTS), browser-based options, RSS feed generation for podcast apps, and file hosting options. The solution should be cost-effective or free since budget is a concern.', type: 'research', - status: 'backlog', + status: 'open', priority: 'medium', projectId: '2', sprintId: 'sprint-1',