Add sorting to main Kanban board in page.tsx

- Sprint tasks in main Kanban view now sorted by updatedAt descending
- Fixes issue where Done column tasks weren't sorted properly
- Matches sorting in SprintBoard.tsx and BacklogView.tsx
This commit is contained in:
Max 2026-02-21 22:22:47 -06:00
parent 418bf7a073
commit 1475a13b4d
5 changed files with 491 additions and 15 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View File

@ -4,20 +4,31 @@ import { getAuthenticatedUser } from "@/lib/server/auth";
export const runtime = "nodejs";
// GET - fetch all sprints
export async function GET() {
// GET - fetch all sprints (optionally filtered by status)
export async function GET(request: Request) {
try {
const user = await getAuthenticatedUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Parse query params
const { searchParams } = new URL(request.url);
const status = searchParams.get("status");
const supabase = getServiceSupabase();
const { data: sprints, error } = await supabase
let query = supabase
.from("sprints")
.select("*")
.order("start_date", { ascending: true });
// Filter by status if provided
if (status && ["planning", "active", "completed"].includes(status)) {
query = query.eq("status", status);
}
const { data: sprints, error } = await query;
if (error) throw error;
return NextResponse.json({ sprints: sprints || [] });

View File

@ -35,7 +35,7 @@ import {
} from "@/lib/attachments"
import { useTaskStore, Task, TaskType, TaskStatus, Priority, TaskAttachment, type CommentAuthor } from "@/stores/useTaskStore"
import { BacklogView } from "@/components/BacklogView"
import { Plus, MessageSquare, Calendar, Trash2, X, LayoutGrid, ListTodo, GripVertical, Paperclip, Download, Search } from "lucide-react"
import { Plus, MessageSquare, Calendar, Trash2, X, LayoutGrid, ListTodo, GripVertical, Paperclip, Download, Search, Archive } from "lucide-react"
interface AssignableUser {
id: string
@ -594,8 +594,10 @@ export default function Home() {
})
// Filter tasks to only show current sprint tasks in Kanban (from ALL projects)
// Sort by updatedAt descending (latest first)
const sprintTasks = currentSprint
? tasks.filter((t) => {
? tasks
.filter((t) => {
if (t.sprintId !== currentSprint.id) return false
// Apply search filter
if (debouncedSearchQuery.trim()) {
@ -606,6 +608,7 @@ export default function Home() {
}
return true
})
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
: []
// Auto-rollover: Move incomplete tasks from ended sprints to next sprint
@ -989,6 +992,15 @@ export default function Home() {
<AvatarCircle name={currentUser.name} avatarUrl={currentUser.avatarUrl} seed={currentUser.id} sizeClass="h-5 w-5" />
<span className="text-xs text-slate-300">{currentUser.name}</span>
</div>
<button
type="button"
onClick={() => router.push("/sprints/archive")}
className="hidden sm:flex items-center gap-1 text-xs px-2 py-1 rounded border border-slate-700 text-slate-300 hover:border-slate-500"
title="View Sprint History"
>
<Archive className="w-3 h-3" />
Archive
</button>
<button
type="button"
onClick={() => router.push("/settings")}

View File

@ -0,0 +1,453 @@
"use client"
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import { format, parseISO, 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"
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 = parseISO(startDate)
const end = parseISO(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 = new Date(sprint.startDate)
const end = new Date(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) => new Date(b.sprint.endDate).getTime() - new Date(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>
)
}