Signed-off-by: Max <ai-agent@topdoglabs.com>

This commit is contained in:
Max 2026-02-25 15:40:15 -06:00
parent 51ee798148
commit c23d3c4945
4 changed files with 1378 additions and 9 deletions

View File

@ -47,6 +47,7 @@ Task and sprint board built with Next.js + Zustand and Supabase-backed API persi
- Added a dedicated `/sprints` page (previously missing, caused route 404).
- Added sprint management UI on `/sprints` to view all sprints and perform create/edit/delete actions.
- Added a dedicated `/tasks` list page with URL-based filtering and scope controls.
- Removed sprint-to-project coupling across app/API/CLI. Tasks remain project-scoped; sprints are now global.
- Added DB migration script to drop `sprints.project_id`: `supabase/remove_sprint_project_id.sql`.
@ -162,6 +163,17 @@ Task and sprint board built with Next.js + Zustand and Supabase-backed API persi
- Each task now has a shareable deep link.
- Task detail includes explicit Project and Sprint dropdowns.
### Task list route
- Added a dedicated task list route at `/tasks`.
- Supported query params:
- `scope=active-sprint|all` (default: `all`)
- `status=<comma-separated status list>` (for example `in-progress,review`)
- `priority=<comma-separated priority list>` (for example `urgent,high`)
- Unknown status/priority tokens are ignored.
- Filtering is client-side against the synced task dataset.
- Results are sorted by `updatedAt` descending.
### Attachments
- Task detail page supports adding multiple attachments per task.

View File

@ -0,0 +1,329 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { useParams, useRouter } from "next/navigation"
import { ArrowLeft, Calendar, CheckCircle2 } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader } from "@/components/ui/card"
import { parseSprintEnd, parseSprintStart } from "@/lib/utils"
import { useTaskStore, type SprintStatus, type Task, type TaskStatus } from "@/stores/useTaskStore"
const SPRINT_STATUS_LABELS: Record<SprintStatus, string> = {
planning: "Planning",
active: "Active",
completed: "Completed",
}
const SPRINT_STATUS_BADGE_CLASSES: Record<SprintStatus, string> = {
planning: "bg-amber-500/10 text-amber-300 border-amber-500/30",
active: "bg-emerald-500/10 text-emerald-300 border-emerald-500/30",
completed: "bg-slate-500/10 text-slate-300 border-slate-500/30",
}
const TASK_STATUS_LABELS: Record<TaskStatus, string> = {
open: "Open",
todo: "To Do",
blocked: "Blocked",
"in-progress": "In Progress",
review: "Review",
validate: "Validate",
archived: "Archived",
canceled: "Canceled",
done: "Done",
}
function formatDateRange(startDate: string, endDate: string): string {
const start = parseSprintStart(startDate)
const end = parseSprintEnd(endDate)
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
return "Invalid date range"
}
return `${start.toLocaleDateString()} - ${end.toLocaleDateString()}`
}
function toTimestamp(value: string): number {
const timestamp = new Date(value).getTime()
return Number.isNaN(timestamp) ? 0 : timestamp
}
function TaskSection({
title,
emptyState,
tasks,
onTaskClick,
}: {
title: string
emptyState: string
tasks: Task[]
onTaskClick: (taskId: string) => void
}) {
return (
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-3">
<div className="flex items-center justify-between gap-2">
<h3 className="font-semibold text-white">{title}</h3>
<Badge variant="secondary" className="bg-slate-800 text-slate-300">
{tasks.length}
</Badge>
</div>
</CardHeader>
<CardContent>
{tasks.length === 0 ? (
<p className="text-sm text-slate-500">{emptyState}</p>
) : (
<div className="space-y-2">
{tasks.map((task) => (
<button
key={task.id}
type="button"
className="w-full rounded-lg border border-slate-800 bg-slate-950/60 p-3 text-left hover:border-slate-700 transition-colors"
onClick={() => onTaskClick(task.id)}
>
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-sm font-medium text-slate-100">{task.title}</p>
<div className="flex items-center gap-2">
<Badge variant="outline" className="border-slate-700 text-slate-300 text-xs">
{TASK_STATUS_LABELS[task.status]}
</Badge>
<Badge variant="secondary" className="bg-slate-800 text-slate-300 text-xs capitalize">
{task.priority}
</Badge>
</div>
</div>
</button>
))}
</div>
)}
</CardContent>
</Card>
)
}
export default function SprintDetailPage() {
const params = useParams<{ id: string }>()
const router = useRouter()
const routeSprintId = Array.isArray(params.id) ? params.id[0] : params.id
const sprintId = decodeURIComponent(routeSprintId || "")
const { sprints, tasks, syncFromServer, selectSprint, syncError } = useTaskStore()
const [authReady, setAuthReady] = useState(false)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
let isMounted = true
const loadSession = async () => {
try {
const res = await fetch("/api/auth/session", { cache: "no-store" })
if (!res.ok) {
if (isMounted) router.replace("/login")
return
}
if (isMounted) setAuthReady(true)
} catch {
if (isMounted) router.replace("/login")
}
}
void loadSession()
return () => {
isMounted = false
}
}, [router])
useEffect(() => {
if (!authReady) return
let active = true
const loadData = async () => {
setIsLoading(true)
await syncFromServer({ scope: "all" })
if (active) setIsLoading(false)
}
void loadData()
return () => {
active = false
}
}, [authReady, syncFromServer])
const sprint = useMemo(
() => sprints.find((candidate) => candidate.id === sprintId) || null,
[sprints, sprintId]
)
const sprintTasks = useMemo(
() =>
tasks
.filter((task) => task.sprintId === sprintId)
.sort((a, b) => toTimestamp(b.updatedAt) - toTimestamp(a.updatedAt)),
[tasks, sprintId]
)
const completedTasks = useMemo(
() => sprintTasks.filter((task) => task.status === "done" || task.status === "archived"),
[sprintTasks]
)
const canceledTasks = useMemo(
() => sprintTasks.filter((task) => task.status === "canceled"),
[sprintTasks]
)
const activeTasks = useMemo(
() =>
sprintTasks.filter(
(task) => task.status !== "done" && task.status !== "archived" && task.status !== "canceled"
),
[sprintTasks]
)
const completionPercent =
sprintTasks.length > 0 ? Math.round((completedTasks.length / sprintTasks.length) * 100) : 0
const openOnBoard = () => {
if (!sprint) return
selectSprint(sprint.id)
router.push("/")
}
const openTask = (taskId: string) => {
router.push(`/tasks/${encodeURIComponent(taskId)}`)
}
if (!authReady) {
return (
<div className="min-h-screen bg-slate-950 text-slate-100 flex items-center justify-center">
<p className="text-sm text-slate-400">Checking session...</p>
</div>
)
}
return (
<div className="min-h-screen bg-slate-950 text-slate-100">
<header className="border-b border-slate-800 bg-slate-900/50 backdrop-blur">
<div className="max-w-[1800px] mx-auto px-4 py-4">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-4">
<Button
variant="outline"
className="border-slate-700 text-slate-200 hover:bg-slate-800"
onClick={() => router.push("/sprints")}
>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Sprints
</Button>
<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">
Sprint Details
</h1>
<p className="text-xs md:text-sm text-slate-400 mt-1">
Sprint deep link: {sprintId || "Unknown sprint"}
</p>
</div>
</div>
<Button onClick={openOnBoard} disabled={!sprint}>
<CheckCircle2 className="w-4 h-4 mr-2" />
Open on Board
</Button>
</div>
</div>
</header>
<div className="max-w-[1800px] mx-auto px-4 py-6 space-y-4">
{syncError && (
<div className="rounded-lg border border-red-500/30 bg-red-500/10 text-red-200 text-sm px-3 py-2">
Sync error: {syncError}
</div>
)}
{isLoading ? (
<div className="flex items-center justify-center py-12 text-slate-400">
<span>Loading sprint...</span>
</div>
) : !sprint ? (
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-6 space-y-3">
<h2 className="text-lg font-semibold text-white">Sprint not found</h2>
<p className="text-sm text-slate-400">
No sprint was found for ID: <span className="font-mono">{sprintId || "unknown"}</span>
</p>
<Button variant="outline" className="border-slate-700 text-slate-200 hover:bg-slate-800" onClick={() => router.push("/sprints")}>
Back to Sprints
</Button>
</CardContent>
</Card>
) : (
<>
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="text-xl font-semibold text-white">{sprint.name}</h2>
{sprint.goal && <p className="text-sm text-slate-300 mt-1">{sprint.goal}</p>}
</div>
<Badge variant="outline" className={SPRINT_STATUS_BADGE_CLASSES[sprint.status]}>
{SPRINT_STATUS_LABELS[sprint.status]}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-2 text-sm text-slate-400">
<Calendar className="w-4 h-4" />
<span>{formatDateRange(sprint.startDate, sprint.endDate)}</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className="rounded-lg border border-slate-800 bg-slate-950/60 px-4 py-3">
<p className="text-xs text-slate-400">Total Tasks</p>
<p className="text-2xl font-semibold text-white mt-1">{sprintTasks.length}</p>
</div>
<div className="rounded-lg border border-slate-800 bg-slate-950/60 px-4 py-3">
<p className="text-xs text-slate-400">Completed</p>
<p className="text-2xl font-semibold text-emerald-300 mt-1">{completedTasks.length}</p>
</div>
<div className="rounded-lg border border-slate-800 bg-slate-950/60 px-4 py-3">
<p className="text-xs text-slate-400">Completion</p>
<p className="text-2xl font-semibold text-blue-300 mt-1">{completionPercent}%</p>
</div>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between text-xs text-slate-400">
<span>Progress</span>
<span>{completedTasks.length}/{sprintTasks.length}</span>
</div>
<div className="h-2 rounded-full bg-slate-800 overflow-hidden">
<div
className="h-full bg-blue-500 transition-all"
style={{ width: `${completionPercent}%` }}
/>
</div>
</div>
</CardContent>
</Card>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4">
<TaskSection
title="Active Tasks"
emptyState="No active tasks in this sprint."
tasks={activeTasks}
onTaskClick={openTask}
/>
<TaskSection
title="Completed Tasks"
emptyState="No completed tasks in this sprint."
tasks={completedTasks}
onTaskClick={openTask}
/>
<TaskSection
title="Canceled Tasks"
emptyState="No canceled tasks in this sprint."
tasks={canceledTasks}
onTaskClick={openTask}
/>
</div>
</>
)}
</div>
</div>
)
}

View File

@ -66,7 +66,6 @@ export default function SprintsPage() {
const {
tasks,
sprints,
selectSprint,
syncFromServer,
syncError,
} = useTaskStore()
@ -240,9 +239,8 @@ export default function SprintsPage() {
}
}
const openOnBoard = (sprintId: string) => {
selectSprint(sprintId)
router.push("/")
const openSprintDetails = (sprintId: string) => {
router.push(`/sprints/${encodeURIComponent(sprintId)}`)
}
if (!authReady) {
@ -347,11 +345,17 @@ export default function SprintsPage() {
const completion = counts.total > 0 ? Math.round((counts.done / counts.total) * 100) : 0
return (
<Card key={sprint.id} className="bg-slate-900 border-slate-800 hover:border-slate-600 transition-colors">
<Card
key={sprint.id}
className="bg-slate-900 border-slate-800 hover:border-slate-600 transition-colors cursor-pointer group"
onClick={() => openSprintDetails(sprint.id)}
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="font-semibold text-white truncate">{sprint.name}</h3>
<h3 className="font-semibold text-white truncate group-hover:text-blue-400 transition-colors">
{sprint.name}
</h3>
</div>
<Badge variant="outline" className={STATUS_BADGE_CLASSES[sprint.status]}>
{STATUS_LABELS[sprint.status]}
@ -384,7 +388,10 @@ export default function SprintsPage() {
size="sm"
variant="outline"
className="border-slate-700 text-slate-200 hover:bg-slate-800"
onClick={() => openOnBoard(sprint.id)}
onClick={(event) => {
event.stopPropagation()
openSprintDetails(sprint.id)
}}
>
<CheckCircle2 className="w-3.5 h-3.5 mr-1.5" />
Open
@ -393,7 +400,10 @@ export default function SprintsPage() {
size="sm"
variant="outline"
className="border-slate-700 text-slate-200 hover:bg-slate-800"
onClick={() => openEditDialog(sprint)}
onClick={(event) => {
event.stopPropagation()
openEditDialog(sprint)
}}
>
<Pencil className="w-3.5 h-3.5 mr-1.5" />
Edit
@ -402,7 +412,10 @@ export default function SprintsPage() {
size="sm"
variant="outline"
className="border-red-500/40 text-red-300 hover:bg-red-500/10"
onClick={() => setDeleteConfirmId(sprint.id)}
onClick={(event) => {
event.stopPropagation()
setDeleteConfirmId(sprint.id)
}}
>
<Trash2 className="w-3.5 h-3.5 mr-1.5" />
Delete

1015
src/app/tasks/page.tsx Normal file

File diff suppressed because it is too large Load Diff