Signed-off-by: Max <ai-agent@topdoglabs.com>
This commit is contained in:
parent
51ee798148
commit
c23d3c4945
12
README.md
12
README.md
@ -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 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 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.
|
- 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`.
|
- 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.
|
- Each task now has a shareable deep link.
|
||||||
- Task detail includes explicit Project and Sprint dropdowns.
|
- 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
|
### Attachments
|
||||||
|
|
||||||
- Task detail page supports adding multiple attachments per task.
|
- Task detail page supports adding multiple attachments per task.
|
||||||
|
|||||||
329
src/app/sprints/[id]/page.tsx
Normal file
329
src/app/sprints/[id]/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -66,7 +66,6 @@ export default function SprintsPage() {
|
|||||||
const {
|
const {
|
||||||
tasks,
|
tasks,
|
||||||
sprints,
|
sprints,
|
||||||
selectSprint,
|
|
||||||
syncFromServer,
|
syncFromServer,
|
||||||
syncError,
|
syncError,
|
||||||
} = useTaskStore()
|
} = useTaskStore()
|
||||||
@ -240,9 +239,8 @@ export default function SprintsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const openOnBoard = (sprintId: string) => {
|
const openSprintDetails = (sprintId: string) => {
|
||||||
selectSprint(sprintId)
|
router.push(`/sprints/${encodeURIComponent(sprintId)}`)
|
||||||
router.push("/")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!authReady) {
|
if (!authReady) {
|
||||||
@ -347,11 +345,17 @@ export default function SprintsPage() {
|
|||||||
const completion = counts.total > 0 ? Math.round((counts.done / counts.total) * 100) : 0
|
const completion = counts.total > 0 ? Math.round((counts.done / counts.total) * 100) : 0
|
||||||
|
|
||||||
return (
|
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">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<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>
|
</div>
|
||||||
<Badge variant="outline" className={STATUS_BADGE_CLASSES[sprint.status]}>
|
<Badge variant="outline" className={STATUS_BADGE_CLASSES[sprint.status]}>
|
||||||
{STATUS_LABELS[sprint.status]}
|
{STATUS_LABELS[sprint.status]}
|
||||||
@ -384,7 +388,10 @@ export default function SprintsPage() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="border-slate-700 text-slate-200 hover:bg-slate-800"
|
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" />
|
<CheckCircle2 className="w-3.5 h-3.5 mr-1.5" />
|
||||||
Open
|
Open
|
||||||
@ -393,7 +400,10 @@ export default function SprintsPage() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="border-slate-700 text-slate-200 hover:bg-slate-800"
|
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" />
|
<Pencil className="w-3.5 h-3.5 mr-1.5" />
|
||||||
Edit
|
Edit
|
||||||
@ -402,7 +412,10 @@ export default function SprintsPage() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="border-red-500/40 text-red-300 hover:bg-red-500/10"
|
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" />
|
<Trash2 className="w-3.5 h-3.5 mr-1.5" />
|
||||||
Delete
|
Delete
|
||||||
|
|||||||
1015
src/app/tasks/page.tsx
Normal file
1015
src/app/tasks/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user