455 lines
19 KiB
TypeScript
455 lines
19 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useState } from "react"
|
|
import { useRouter } from "next/navigation"
|
|
import { format, isValid } from "date-fns"
|
|
import { ArrowLeft, Calendar, CheckCircle2, Target, TrendingUp, Clock, Archive, ChevronRight, X } from "lucide-react"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Card, CardContent, CardHeader } from "@/components/ui/card"
|
|
import { Badge } from "@/components/ui/badge"
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
|
import { parseSprintEnd, parseSprintStart } from "@/lib/utils"
|
|
|
|
interface Sprint {
|
|
id: string
|
|
name: string
|
|
goal: string | null
|
|
startDate: string
|
|
endDate: string
|
|
status: "planning" | "active" | "completed"
|
|
projectId: string
|
|
createdAt: string
|
|
}
|
|
|
|
interface Task {
|
|
id: string
|
|
title: string
|
|
status: string
|
|
type: string
|
|
priority: string
|
|
sprintId: string | null
|
|
}
|
|
|
|
interface SprintStats {
|
|
sprint: Sprint
|
|
totalTasks: number
|
|
completedTasks: number
|
|
completionRate: number
|
|
velocity: number // tasks per day
|
|
durationDays: number
|
|
}
|
|
|
|
interface SprintDetail {
|
|
sprint: Sprint
|
|
tasks: Task[]
|
|
completedTasks: Task[]
|
|
rolledOverTasks: Task[]
|
|
}
|
|
|
|
function formatDateRange(startDate: string, endDate: string): string {
|
|
const start = parseSprintStart(startDate)
|
|
const end = parseSprintEnd(endDate)
|
|
if (!isValid(start) || !isValid(end)) return "Invalid dates"
|
|
return `${format(start, "MMM d")} - ${format(end, "MMM d, yyyy")}`
|
|
}
|
|
|
|
function formatDuration(days: number): string {
|
|
if (days < 1) return "< 1 day"
|
|
if (days === 1) return "1 day"
|
|
return `${days} days`
|
|
}
|
|
|
|
export default function SprintArchivePage() {
|
|
const router = useRouter()
|
|
const [authReady, setAuthReady] = useState(false)
|
|
const [isLoading, setIsLoading] = useState(true)
|
|
const [sprintStats, setSprintStats] = useState<SprintStats[]>([])
|
|
const [selectedSprint, setSelectedSprint] = useState<SprintDetail | null>(null)
|
|
const [detailOpen, setDetailOpen] = useState(false)
|
|
|
|
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")
|
|
}
|
|
}
|
|
loadSession()
|
|
return () => {
|
|
isMounted = false
|
|
}
|
|
}, [router])
|
|
|
|
useEffect(() => {
|
|
if (!authReady) return
|
|
|
|
const fetchData = async () => {
|
|
setIsLoading(true)
|
|
try {
|
|
// Fetch completed sprints
|
|
const sprintsRes = await fetch("/api/sprints?status=completed")
|
|
const sprintsData = await sprintsRes.json()
|
|
const completedSprints: Sprint[] = (sprintsData.sprints || []).filter(
|
|
(s: Sprint) => s.status === "completed"
|
|
)
|
|
|
|
// Fetch all tasks
|
|
const tasksRes = await fetch("/api/tasks")
|
|
const tasksData = await tasksRes.json()
|
|
const allTasks: Task[] = tasksData.tasks || []
|
|
|
|
// Calculate stats for each sprint
|
|
const stats: SprintStats[] = completedSprints.map((sprint) => {
|
|
const sprintTasks = allTasks.filter((t) => t.sprintId === sprint.id)
|
|
const completedTasks = sprintTasks.filter(
|
|
(t) => t.status === "done" || t.status === "archived"
|
|
)
|
|
|
|
const start = parseSprintStart(sprint.startDate)
|
|
const end = parseSprintEnd(sprint.endDate)
|
|
const durationMs = end.getTime() - start.getTime()
|
|
const durationDays = Math.max(1, Math.ceil(durationMs / (1000 * 60 * 60 * 24)))
|
|
|
|
return {
|
|
sprint,
|
|
totalTasks: sprintTasks.length,
|
|
completedTasks: completedTasks.length,
|
|
completionRate: sprintTasks.length > 0
|
|
? Math.round((completedTasks.length / sprintTasks.length) * 100)
|
|
: 0,
|
|
velocity: parseFloat((completedTasks.length / durationDays).toFixed(2)),
|
|
durationDays,
|
|
}
|
|
})
|
|
|
|
// Sort by end date (most recent first)
|
|
stats.sort((a, b) => parseSprintEnd(b.sprint.endDate).getTime() - parseSprintEnd(a.sprint.endDate).getTime())
|
|
setSprintStats(stats)
|
|
} catch (error) {
|
|
console.error("Failed to fetch sprint data:", error)
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}
|
|
|
|
fetchData()
|
|
}, [authReady])
|
|
|
|
const handleSprintClick = async (sprintId: string) => {
|
|
try {
|
|
const [sprintRes, tasksRes] = await Promise.all([
|
|
fetch(`/api/sprints/${sprintId}`),
|
|
fetch("/api/tasks"),
|
|
])
|
|
|
|
const sprintData = await sprintRes.json()
|
|
const tasksData = await tasksRes.json()
|
|
|
|
const sprint: Sprint = sprintData.sprint
|
|
const allTasks: Task[] = tasksData.tasks || []
|
|
const sprintTasks = allTasks.filter((t) => t.sprintId === sprintId)
|
|
const completedTasks = sprintTasks.filter(
|
|
(t) => t.status === "done" || t.status === "archived"
|
|
)
|
|
const rolledOverTasks = sprintTasks.filter(
|
|
(t) => t.status !== "done" && t.status !== "archived" && t.status !== "canceled"
|
|
)
|
|
|
|
setSelectedSprint({
|
|
sprint,
|
|
tasks: sprintTasks,
|
|
completedTasks,
|
|
rolledOverTasks,
|
|
})
|
|
setDetailOpen(true)
|
|
} catch (error) {
|
|
console.error("Failed to fetch sprint details:", error)
|
|
}
|
|
}
|
|
|
|
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 */}
|
|
<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">
|
|
<div className="flex items-center gap-4">
|
|
<Button
|
|
variant="outline"
|
|
className="border-slate-700 text-slate-200 hover:bg-slate-800"
|
|
onClick={() => router.push("/")}
|
|
>
|
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
|
Back to Board
|
|
</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 Archive
|
|
</h1>
|
|
<p className="text-xs md:text-sm text-slate-400 mt-1">
|
|
View completed sprints and their metrics
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<div className="max-w-[1800px] mx-auto px-4 py-6">
|
|
{isLoading ? (
|
|
<div className="flex items-center justify-center py-12">
|
|
<div className="flex items-center gap-2 text-slate-400">
|
|
<svg className="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
</svg>
|
|
<span>Loading sprint history...</span>
|
|
</div>
|
|
</div>
|
|
) : sprintStats.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-16 text-slate-400">
|
|
<Archive className="w-16 h-16 mb-4 text-slate-600" />
|
|
<h3 className="text-lg font-medium text-slate-300 mb-2">No Completed Sprints</h3>
|
|
<p className="text-sm mb-4 text-center max-w-md">
|
|
When sprints are marked as completed, they will appear here with their metrics and statistics.
|
|
</p>
|
|
<Button onClick={() => router.push("/")}>
|
|
Go to Active Board
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{sprintStats.map((stat) => (
|
|
<Card
|
|
key={stat.sprint.id}
|
|
className="bg-slate-900 border-slate-800 hover:border-slate-600 cursor-pointer transition-all hover:shadow-lg hover:shadow-slate-900/50 group"
|
|
onClick={() => handleSprintClick(stat.sprint.id)}
|
|
>
|
|
<CardHeader className="pb-3">
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex-1 min-w-0">
|
|
<h3 className="font-semibold text-white truncate group-hover:text-blue-400 transition-colors">
|
|
{stat.sprint.name}
|
|
</h3>
|
|
{stat.sprint.goal && (
|
|
<p className="text-sm text-slate-400 mt-1 line-clamp-2">{stat.sprint.goal}</p>
|
|
)}
|
|
</div>
|
|
<Badge variant="secondary" className="bg-emerald-500/10 text-emerald-400 border-emerald-500/20 ml-2 shrink-0">
|
|
Completed
|
|
</Badge>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{/* Date Range */}
|
|
<div className="flex items-center gap-2 text-sm text-slate-400">
|
|
<Calendar className="w-4 h-4" />
|
|
<span>{formatDateRange(stat.sprint.startDate, stat.sprint.endDate)}</span>
|
|
</div>
|
|
|
|
{/* Stats Grid */}
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="bg-slate-800/50 rounded-lg p-3">
|
|
<div className="flex items-center gap-2 text-slate-400 mb-1">
|
|
<Target className="w-4 h-4" />
|
|
<span className="text-xs">Total Tasks</span>
|
|
</div>
|
|
<p className="text-2xl font-bold text-white">{stat.totalTasks}</p>
|
|
</div>
|
|
<div className="bg-slate-800/50 rounded-lg p-3">
|
|
<div className="flex items-center gap-2 text-slate-400 mb-1">
|
|
<CheckCircle2 className="w-4 h-4" />
|
|
<span className="text-xs">Completed</span>
|
|
</div>
|
|
<p className="text-2xl font-bold text-emerald-400">{stat.completedTasks}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Completion Rate */}
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between text-sm">
|
|
<span className="text-slate-400">Completion Rate</span>
|
|
<span className="font-medium text-white">{stat.completionRate}%</span>
|
|
</div>
|
|
<div className="h-2 bg-slate-800 rounded-full overflow-hidden">
|
|
<div
|
|
className={`h-full rounded-full transition-all ${
|
|
stat.completionRate >= 80
|
|
? "bg-emerald-500"
|
|
: stat.completionRate >= 50
|
|
? "bg-blue-500"
|
|
: "bg-amber-500"
|
|
}`}
|
|
style={{ width: `${stat.completionRate}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Velocity & Duration */}
|
|
<div className="flex items-center justify-between pt-2 border-t border-slate-800">
|
|
<div className="flex items-center gap-2 text-sm text-slate-400">
|
|
<TrendingUp className="w-4 h-4" />
|
|
<span>Velocity: <span className="text-slate-200">{stat.velocity}</span> tasks/day</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 text-sm text-slate-400">
|
|
<Clock className="w-4 h-4" />
|
|
<span>{formatDuration(stat.durationDays)}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Click hint */}
|
|
<div className="flex items-center justify-center gap-1 text-xs text-slate-500 group-hover:text-blue-400 transition-colors pt-1">
|
|
<span>View Details</span>
|
|
<ChevronRight className="w-3 h-3" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Sprint Detail Dialog */}
|
|
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
|
<DialogContent className="bg-slate-900 border-slate-800 text-white w-[95vw] max-w-3xl max-h-[90vh] overflow-y-auto">
|
|
{selectedSprint && (
|
|
<>
|
|
<DialogHeader>
|
|
<DialogTitle className="sr-only">Sprint Details</DialogTitle>
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<h2 className="text-xl font-semibold text-white">{selectedSprint.sprint.name}</h2>
|
|
{selectedSprint.sprint.goal && (
|
|
<p className="text-sm text-slate-400 mt-1">{selectedSprint.sprint.goal}</p>
|
|
)}
|
|
</div>
|
|
<Badge variant="secondary" className="bg-emerald-500/10 text-emerald-400 border-emerald-500/20">
|
|
Completed
|
|
</Badge>
|
|
</div>
|
|
<div className="flex items-center gap-2 text-sm text-slate-400 mt-2">
|
|
<Calendar className="w-4 h-4" />
|
|
<span>{formatDateRange(selectedSprint.sprint.startDate, selectedSprint.sprint.endDate)}</span>
|
|
</div>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-6 py-4">
|
|
{/* Summary Stats */}
|
|
<div className="grid grid-cols-3 gap-4">
|
|
<div className="bg-slate-800/50 rounded-lg p-4 text-center">
|
|
<p className="text-3xl font-bold text-white">{selectedSprint.tasks.length}</p>
|
|
<p className="text-sm text-slate-400 mt-1">Total Tasks</p>
|
|
</div>
|
|
<div className="bg-slate-800/50 rounded-lg p-4 text-center">
|
|
<p className="text-3xl font-bold text-emerald-400">{selectedSprint.completedTasks.length}</p>
|
|
<p className="text-sm text-slate-400 mt-1">Completed</p>
|
|
</div>
|
|
<div className="bg-slate-800/50 rounded-lg p-4 text-center">
|
|
<p className="text-3xl font-bold text-amber-400">{selectedSprint.rolledOverTasks.length}</p>
|
|
<p className="text-sm text-slate-400 mt-1">Rolled Over</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Completed Tasks */}
|
|
{selectedSprint.completedTasks.length > 0 && (
|
|
<div>
|
|
<h3 className="text-sm font-medium text-slate-200 mb-3 flex items-center gap-2">
|
|
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
|
|
Completed Tasks ({selectedSprint.completedTasks.length})
|
|
</h3>
|
|
<div className="space-y-2">
|
|
{selectedSprint.completedTasks.map((task) => (
|
|
<div
|
|
key={task.id}
|
|
className="flex items-center gap-3 p-3 bg-slate-800/30 rounded-lg border border-slate-800"
|
|
>
|
|
<Badge className="bg-emerald-500/20 text-emerald-400 border-0 text-xs">
|
|
{task.type}
|
|
</Badge>
|
|
<span className="text-sm text-slate-200 flex-1">{task.title}</span>
|
|
<Badge variant="outline" className="text-xs border-slate-600 text-slate-400">
|
|
{task.priority}
|
|
</Badge>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Rolled Over Tasks */}
|
|
{selectedSprint.rolledOverTasks.length > 0 && (
|
|
<div>
|
|
<h3 className="text-sm font-medium text-slate-200 mb-3 flex items-center gap-2">
|
|
<Clock className="w-4 h-4 text-amber-400" />
|
|
Rolled Over Tasks ({selectedSprint.rolledOverTasks.length})
|
|
</h3>
|
|
<div className="space-y-2">
|
|
{selectedSprint.rolledOverTasks.map((task) => (
|
|
<div
|
|
key={task.id}
|
|
className="flex items-center gap-3 p-3 bg-slate-800/30 rounded-lg border border-slate-800"
|
|
>
|
|
<Badge className="bg-amber-500/20 text-amber-400 border-0 text-xs">
|
|
{task.type}
|
|
</Badge>
|
|
<span className="text-sm text-slate-200 flex-1">{task.title}</span>
|
|
<Badge variant="outline" className="text-xs border-slate-600 text-slate-400">
|
|
{task.status}
|
|
</Badge>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Canceled Tasks */}
|
|
{selectedSprint.tasks.filter((t) => t.status === "canceled").length > 0 && (
|
|
<div>
|
|
<h3 className="text-sm font-medium text-slate-200 mb-3 flex items-center gap-2">
|
|
<X className="w-4 h-4 text-red-400" />
|
|
Canceled Tasks ({selectedSprint.tasks.filter((t) => t.status === "canceled").length})
|
|
</h3>
|
|
<div className="space-y-2">
|
|
{selectedSprint.tasks
|
|
.filter((t) => t.status === "canceled")
|
|
.map((task) => (
|
|
<div
|
|
key={task.id}
|
|
className="flex items-center gap-3 p-3 bg-slate-800/30 rounded-lg border border-slate-800 opacity-60"
|
|
>
|
|
<Badge className="bg-red-500/20 text-red-400 border-0 text-xs">
|
|
{task.type}
|
|
</Badge>
|
|
<span className="text-sm text-slate-400 flex-1 line-through">{task.title}</span>
|
|
<Badge variant="outline" className="text-xs border-slate-600 text-slate-500">
|
|
canceled
|
|
</Badge>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
)
|
|
}
|