diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 2ebdcbc76..90eddcc55 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -54,6 +54,12 @@ export default async function DashboardPage() {
{/* Quick actions */} + + πŸ“Š Sprint Analytics +
+

{label}

+

+ {value} +

+ {sub &&

{sub}

} +
+ ); +} + +// --------------------------------------------------------------------------- +// Main page +// --------------------------------------------------------------------------- +export default function SprintAnalytics() { + const [sprintStart] = useState(MOCK_SPRINT_START); + const [sprintEnd] = useState(MOCK_SPRINT_END); + const [issues] = useState(MOCK_ISSUES); + + const { + data, + totalPoints, + velocity, + predictedCompletionDay, + isDelayed, + completedPoints, + remainingPoints, + } = useBurnDownData({ issues, sprintStartDate: sprintStart, sprintEndDate: sprintEnd }); + + const sprintDays = useMemo(() => { + const ms = sprintEnd.getTime() - sprintStart.getTime(); + return Math.round(ms / (1000 * 60 * 60 * 24)) + 1; + }, [sprintStart, sprintEnd]); + + const currentDay = useMemo(() => { + const ms = Date.now() - sprintStart.getTime(); + return Math.min(Math.max(Math.round(ms / 86400000) + 1, 1), sprintDays); + }, [sprintStart, sprintDays]); + + const completionPercent = Math.round((completedPoints / totalPoints) * 100); + + const delayDays = + isDelayed && predictedCompletionDay != null + ? predictedCompletionDay - sprintDays + : null; + + return ( +
+
+ {/* Header */} +
+
+ πŸ“Š +

Sprint Analytics

+
+

+ Real-time burn-down tracking with predictive velocity forecasting +

+
+ + {/* Sprint meta bar */} +
+
+ Milestone + Sprint 4 +
+
+ Total + {totalPoints} pts +
+
+ Day + + {currentDay} / {sprintDays} + +
+
+ Target + + {sprintEnd.toLocaleDateString("en-IN", { + day: "numeric", + month: "short", + })} + +
+ {isDelayed && ( +
+ + ⚠️ {delayDays}d projected delay + +
+ )} + {!isDelayed && predictedCompletionDay != null && ( +
+ + βœ… On track + +
+ )} +
+ + {/* Stats grid */} +
+ + + + +
+ + {/* Progress bar */} +
+
+ Sprint Progress + {completionPercent}% +
+
+
+
+
+ + {/* Chart */} +
+

+ Burn-Down Chart +

+ +
+ + {/* Issue table */} +
+
+

+ Sprint Issues +

+
+
+ + + + + + + + + + + {issues.map((issue) => { + const isNew = + issue.createdAt.getTime() > sprintStart.getTime() + 86400000; + return ( + + + + + + + ); + })} + +
IssuePointsStatusNote
+ {issue.title} + + {issue.storyPoints} + + {issue.closedAt ? ( + + βœ“ Closed + + ) : ( + + Β· Open + + )} + + {isNew && ( + + ↑ Scope Creep + + )} +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/SprintBurnDown.tsx b/src/components/SprintBurnDown.tsx new file mode 100644 index 000000000..661d92b4b --- /dev/null +++ b/src/components/SprintBurnDown.tsx @@ -0,0 +1,251 @@ +"use client"; + +import { + ComposedChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, + ReferenceDot, + ResponsiveContainer, + Area, +} from "recharts"; +import { BurnDownDataPoint } from "./useBurnDownData"; + +interface SprintBurnDownProps { + data: BurnDownDataPoint[]; + totalPoints: number; + sprintDays: number; + velocity: number; + predictedCompletionDay: number | null; + isDelayed: boolean; +} + +interface CustomTooltipProps { + active?: boolean; + payload?: Array<{ name: string; value: number; color: string }>; + label?: string; +} + +const CustomTooltip = ({ active, payload, label }: CustomTooltipProps) => { + if (!active || !payload || payload.length === 0) return null; + + return ( +
+

Day {label}

+ {payload.map((entry) => ( +
+ + {entry.name}: + + {entry.value != null ? `${entry.value} pts` : "β€”"} + +
+ ))} +
+ ); +}; + +export default function SprintBurnDown({ + data, + totalPoints, + velocity, + predictedCompletionDay, + isDelayed, +}: SprintBurnDownProps) { + // Find scope creep spikes + const scopeCreepDays = data.filter((d) => d.scopeCreep); + + return ( +
+ {/* Legend pills */} +
+ + + Ideal Burn + + + + Actual Progress + + + + Forecast + + {scopeCreepDays.length > 0 && ( + + + Scope Creep + + )} +
+ + + + + + + + + + + + + + + + + } /> + + {/* Ideal burn area (subtle fill) */} + + + {/* Actual progress with gradient fill */} + { + const { cx, cy, payload } = props; + if (payload?.scopeCreep) { + return ( + + ); + } + if (payload?.actual == null) return ; + return ( + + ); + }} + activeDot={{ r: 5, fill: "#34d399", stroke: "#111827", strokeWidth: 2 }} + connectNulls={false} + /> + + {/* Forecast line */} + + + {/* Scope creep reference dots */} + {scopeCreepDays.map((point) => ( + + ))} + + {/* Predicted completion marker */} + {predictedCompletionDay != null && ( + + )} + + + + {/* Velocity note */} +
+ {isDelayed ? "⚠️" : "βœ…"} + + Velocity: {velocity.toFixed(1)} pts/day.{" "} + {isDelayed + ? `At this pace, sprint will finish ~${predictedCompletionDay != null ? predictedCompletionDay - data.filter(d => d.day <= data.length).length : "?"} days late.` + : "On track to complete before sprint deadline."} + +
+
+ ); +} \ No newline at end of file diff --git a/src/components/useBurnDownData.ts b/src/components/useBurnDownData.ts new file mode 100644 index 000000000..b0567ea21 --- /dev/null +++ b/src/components/useBurnDownData.ts @@ -0,0 +1,180 @@ +import { useMemo } from "react"; + +export interface SprintIssue { + id: string; + title: string; + storyPoints: number; + closedAt: Date | null; // null = still open + createdAt: Date; +} + +export interface BurnDownDataPoint { + day: number; + ideal: number; + actual: number | null; // null for future days + forecast: number | null; // null for past days (before today) + scopeCreep: boolean; // true if new points were added on this day +} + +export interface BurnDownStats { + data: BurnDownDataPoint[]; + totalPoints: number; + velocity: number; // pts burned per day (rolling avg) + predictedCompletionDay: number | null; + isDelayed: boolean; + completedPoints: number; + remainingPoints: number; +} + +interface UseBurnDownDataArgs { + issues: SprintIssue[]; + sprintStartDate: Date; + sprintEndDate: Date; +} + +export function useBurnDownData({ + issues, + sprintStartDate, + sprintEndDate, +}: UseBurnDownDataArgs): BurnDownStats { + return useMemo(() => { + const msPerDay = 1000 * 60 * 60 * 24; + const totalSprintDays = + Math.round( + (sprintEndDate.getTime() - sprintStartDate.getTime()) / msPerDay + ) + 1; + + const today = new Date(); + const currentDay = Math.min( + Math.max( + Math.round( + (today.getTime() - sprintStartDate.getTime()) / msPerDay + ) + 1, + 1 + ), + totalSprintDays + ); + + // Compute total points per day (to detect scope creep β€” mid-sprint additions) + const totalPointsByDay: Record = {}; + const burnedPointsByDay: Record = {}; + + issues.forEach((issue) => { + const addedDay = Math.max( + 1, + Math.round( + (issue.createdAt.getTime() - sprintStartDate.getTime()) / msPerDay + ) + 1 + ); + totalPointsByDay[addedDay] = + (totalPointsByDay[addedDay] ?? 0) + issue.storyPoints; + + if (issue.closedAt) { + const closedDay = Math.round( + (issue.closedAt.getTime() - sprintStartDate.getTime()) / msPerDay + ) + 1; + if (closedDay >= 1 && closedDay <= totalSprintDays) { + burnedPointsByDay[closedDay] = + (burnedPointsByDay[closedDay] ?? 0) + issue.storyPoints; + } + } + }); + + // Starting total points = everything added on Day 1 + const initialPoints = totalPointsByDay[1] ?? 0; + const allTotalPoints = issues.reduce((s, i) => s + i.storyPoints, 0); + + // Build cumulative actual remaining points per day + let runningTotal = allTotalPoints; + let runningBurned = 0; + + // Track per-day additions after day 1 (scope creep) + const scopeCreepByDay: Record = {}; + for (const [dayStr, pts] of Object.entries(totalPointsByDay)) { + const day = Number(dayStr); + if (day > 1) { + scopeCreepByDay[day] = pts; + runningTotal += 0; // already counted in allTotalPoints + } + } + + // Build data array + const data: BurnDownDataPoint[] = []; + + // Ideal: linear from allTotalPoints β†’ 0 over totalSprintDays + const idealDecrement = allTotalPoints / (totalSprintDays - 1); + + let cumulativeBurned = 0; + let scopeCumulative = 0; + + for (let day = 1; day <= totalSprintDays; day++) { + const idealRemaining = Math.max( + 0, + allTotalPoints - idealDecrement * (day - 1) + ); + + const burnedToday = burnedPointsByDay[day] ?? 0; + cumulativeBurned += burnedToday; + + const scopeToday = scopeCreepByDay[day] ?? 0; + scopeCumulative += scopeToday; + + const actualRemaining = + day <= currentDay + ? allTotalPoints - cumulativeBurned + : null; + + data.push({ + day, + ideal: Math.round(idealRemaining * 10) / 10, + actual: actualRemaining != null ? Math.round(actualRemaining * 10) / 10 : null, + forecast: null, // filled below + scopeCreep: scopeToday > 0 && day > 1, + }); + } + + // Compute rolling velocity (pts/day burned) over last N actual days + const actualDays = data.filter((d) => d.actual != null); + const windowSize = Math.min(5, actualDays.length - 1); + let velocity = 0; + + if (windowSize > 0) { + const recent = actualDays.slice(-windowSize - 1); + const first = recent[0].actual ?? 0; + const last = recent[recent.length - 1].actual ?? 0; + velocity = (first - last) / windowSize; + } + + if (velocity <= 0) velocity = 0.01; // avoid division by zero + + // Project forecast line from today onward + const currentActual = data[currentDay - 1]?.actual ?? allTotalPoints; + + let predictedCompletionDay: number | null = null; + for (let day = currentDay; day <= totalSprintDays + 30; day++) { + const projected = currentActual - velocity * (day - currentDay); + if (day <= totalSprintDays) { + data[day - 1].forecast = Math.max(0, Math.round(projected * 10) / 10); + } + if (projected <= 0 && predictedCompletionDay == null) { + predictedCompletionDay = day; + } + } + + const completedPoints = cumulativeBurned; + const remainingPoints = allTotalPoints - completedPoints; + const isDelayed = + predictedCompletionDay != null && + predictedCompletionDay > totalSprintDays; + + return { + data, + totalPoints: allTotalPoints, + velocity, + predictedCompletionDay, + isDelayed, + completedPoints, + remainingPoints, + }; + }, [issues, sprintStartDate, sprintEndDate]); +} \ No newline at end of file