Finalize sprint/backlog status flow and document current behavior
This commit is contained in:
parent
c5cf7703b8
commit
eaf01cf634
120
README.md
120
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.
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
459
src/app/page.tsx
459
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<TaskType, string> = {
|
||||
idea: "bg-purple-500",
|
||||
@ -34,14 +48,14 @@ const priorityColors: Record<Priority, string> = {
|
||||
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<string, TaskStatus> = {
|
||||
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 (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={`rounded-md border transition-colors ${
|
||||
expanded ? "px-3 py-3 text-xs" : "px-2 py-1 text-[11px]"
|
||||
} ${
|
||||
isOver
|
||||
? "border-blue-400/70 bg-blue-500/20 text-blue-200"
|
||||
: "border-slate-700 text-slate-400 bg-slate-900/40"
|
||||
}`}
|
||||
title={`Drop to set status: ${formatStatusLabel(status)}`}
|
||||
>
|
||||
{formatStatusLabel(status)} ({count})
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium text-slate-400">{label}</h3>
|
||||
<Badge variant="secondary" className="bg-slate-800 text-slate-400">
|
||||
{count}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{statusTargets.map((target) => (
|
||||
<KanbanStatusDropTarget
|
||||
key={target.status}
|
||||
status={target.status}
|
||||
count={target.count}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={`space-y-3 min-h-32 rounded-lg p-2 transition-colors ${
|
||||
isOver ? "bg-blue-500/10 ring-1 ring-blue-500/50" : ""
|
||||
}`}
|
||||
>
|
||||
{isDragging && isActiveDropColumn ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-slate-400 px-1">Drop into an exact status:</p>
|
||||
{statusTargets.map((target) => (
|
||||
<KanbanStatusDropTarget
|
||||
key={`expanded-${target.status}`}
|
||||
status={target.status}
|
||||
count={target.count}
|
||||
expanded
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<Card
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className="bg-slate-900 border-slate-800 hover:border-slate-700 cursor-pointer transition-colors group"
|
||||
onClick={onOpen}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="h-7 w-6 shrink-0 rounded border border-slate-700/80 bg-slate-800/70 flex items-center justify-center text-slate-400 hover:text-slate-200 cursor-grab active:cursor-grabbing"
|
||||
title="Drag task"
|
||||
aria-label="Drag task"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVertical className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-xs ${typeColors[task.type]} text-white border-0`}
|
||||
>
|
||||
{typeLabels[task.type]}
|
||||
</Badge>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete()
|
||||
}}
|
||||
className="p-1 hover:bg-slate-800 rounded"
|
||||
>
|
||||
<Trash2 className="w-3 h-3 text-slate-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h4 className="font-medium text-white mb-1">{task.title}</h4>
|
||||
{task.description && (
|
||||
<p className="text-sm text-slate-400 line-clamp-2 mb-3">
|
||||
{task.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={priorityColors[task.priority]}>
|
||||
{task.priority}
|
||||
</span>
|
||||
{task.comments && task.comments.length > 0 && (
|
||||
<span className="flex items-center gap-1 text-slate-500">
|
||||
<MessageSquare className="w-3 h-3" />
|
||||
{task.comments.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{task.dueDate && (
|
||||
<span className="text-slate-500 flex items-center gap-1">
|
||||
<Calendar className="w-3 h-3" />
|
||||
{new Date(task.dueDate).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{taskTags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-3">
|
||||
{taskTags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="text-xs px-2 py-0.5 bg-slate-800 text-slate-400 rounded"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
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<Task | null>(null)
|
||||
const [newTaskLabelInput, setNewTaskLabelInput] = useState("")
|
||||
const [editedTaskLabelInput, setEditedTaskLabelInput] = useState("")
|
||||
const [activeKanbanTaskId, setActiveKanbanTaskId] = useState<string | null>(null)
|
||||
const [dragOverKanbanColumnKey, setDragOverKanbanColumnKey] = useState<string | null>(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() {
|
||||
</div>
|
||||
</div>
|
||||
{/* Kanban Columns */}
|
||||
<DndContext
|
||||
sensors={kanbanSensors}
|
||||
collisionDetection={closestCorners}
|
||||
onDragStart={handleKanbanDragStart}
|
||||
onDragOver={handleKanbanDragOver}
|
||||
onDragEnd={handleKanbanDragEnd}
|
||||
onDragCancel={handleKanbanDragCancel}
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{sprintColumns.map((column) => {
|
||||
// Filter tasks by column statuses
|
||||
const columnTasks = sprintTasks.filter((t) =>
|
||||
column.statuses.includes(t.status)
|
||||
)
|
||||
return (
|
||||
<div key={column.key} className="flex flex-col">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium text-slate-400">
|
||||
{column.label}
|
||||
</h3>
|
||||
<Badge variant="secondary" className="bg-slate-800 text-slate-400">
|
||||
{columnTasks.length}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{columnTasks.map((task) => {
|
||||
const taskTags = getTags(task)
|
||||
return (
|
||||
<Card
|
||||
<KanbanDropColumn
|
||||
key={column.key}
|
||||
id={`kanban-col-${column.key}`}
|
||||
label={column.label}
|
||||
count={columnTasks.length}
|
||||
statusTargets={column.statuses.map((status) => ({
|
||||
status,
|
||||
count: sprintTasks.filter((task) => task.status === status).length,
|
||||
}))}
|
||||
isDragging={!!activeKanbanTaskId}
|
||||
isActiveDropColumn={dragOverKanbanColumnKey === column.key}
|
||||
>
|
||||
{columnTasks.map((task) => (
|
||||
<KanbanTaskCard
|
||||
key={task.id}
|
||||
className="bg-slate-900 border-slate-800 hover:border-slate-700 cursor-pointer transition-colors group"
|
||||
onClick={() => selectTask(task.id)}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-xs ${typeColors[task.type]} text-white border-0`}
|
||||
>
|
||||
{typeLabels[task.type]}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-1">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
selectTask(task.id)
|
||||
}}
|
||||
className="p-1 hover:bg-slate-800 rounded"
|
||||
>
|
||||
<Edit2 className="w-3 h-3 text-slate-400" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
deleteTask(task.id)
|
||||
}}
|
||||
className="p-1 hover:bg-slate-800 rounded"
|
||||
>
|
||||
<Trash2 className="w-3 h-3 text-slate-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 className="font-medium text-white mb-1">{task.title}</h4>
|
||||
{task.description && (
|
||||
<p className="text-sm text-slate-400 line-clamp-2 mb-3">
|
||||
{task.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={priorityColors[task.priority]}>
|
||||
{task.priority}
|
||||
</span>
|
||||
{task.comments && task.comments.length > 0 && (
|
||||
<span className="flex items-center gap-1 text-slate-500">
|
||||
<MessageSquare className="w-3 h-3" />
|
||||
{task.comments.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{task.dueDate && (
|
||||
<span className="text-slate-500 flex items-center gap-1">
|
||||
<Calendar className="w-3 h-3" />
|
||||
{new Date(task.dueDate).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{taskTags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-3">
|
||||
{taskTags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="text-xs px-2 py-0.5 bg-slate-800 text-slate-400 rounded"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
task={task}
|
||||
taskTags={getTags(task)}
|
||||
onOpen={() => selectTask(task.id)}
|
||||
onDelete={() => deleteTask(task.id)}
|
||||
/>
|
||||
))}
|
||||
</KanbanDropColumn>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<DragOverlay>
|
||||
{activeKanbanTask ? (
|
||||
<Card className="bg-slate-900 border-slate-700 shadow-2xl rotate-1">
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<GripVertical className="w-3.5 h-3.5 text-slate-400" />
|
||||
<span className="text-sm font-medium text-white">{activeKanbanTask.title}</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
|
||||
@ -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" })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user