Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 | export const dynamic = "force-dynamic"; /** * Dev Burndown Metrics API * GET /api/dev/metrics/burndown - Get burndown chart data for a sprint */ import { NextRequest, NextResponse } from 'next/server'; import { } from 'next-auth'; import { withAdmin, withErrorHandling, successResponse, ApiError, ApiSuccessResponse, ApiErrorResponse } from '@/lib/api'; import { } from '@/lib/api/middleware'; import { prisma } from '@/lib/prisma'; interface BurndownDataPoint { date: string; idealRemaining: number; actualRemaining: number; completed: number; } async function handleGet( request: NextRequest ): Promise<NextResponse<ApiSuccessResponse<unknown> | ApiErrorResponse>> { const { searchParams } = new URL(request.url); const sprintId = searchParams.get('sprintId'); if (!sprintId) { throw ApiError.badRequest('Sprint ID is required'); } // Get sprint details const sprint = await prisma.devSprint.findUnique({ where: { id: sprintId }, include: { project: { select: { id: true, name: true, key: true, color: true } }, tickets: { select: { id: true, ticketNumber: true, title: true, status: true, storyPoints: true, completedAt: true, createdAt: true } } } }); if (!sprint) { throw ApiError.notFound('Sprint not found'); } const startDate = new Date(sprint.startDate); const endDate = new Date(sprint.endDate); const now = new Date(); // Calculate total story points at sprint start const totalPoints = sprint.tickets.reduce( (sum, t) => sum + (t.storyPoints || 0), 0 ); // Generate date range const dates: Date[] = []; const currentDate = new Date(startDate); while (currentDate <= endDate) { dates.push(new Date(currentDate)); currentDate.setDate(currentDate.getDate() + 1); } const sprintDuration = dates.length; const pointsPerDay = totalPoints / (sprintDuration - 1 || 1); // Calculate burndown data const burndownData: BurndownDataPoint[] = dates.map((date, index) => { const dateStr = date.toISOString().split('T')[0]; // Ideal remaining (linear burndown) const idealRemaining = Math.max(0, totalPoints - pointsPerDay * index); // Actual remaining (based on completed tickets up to this date) const completedByDate = sprint.tickets .filter((t) => { if (t.status !== 'COMPLETED' || !t.completedAt) return false; const completedDate = new Date(t.completedAt); return completedDate <= date; }) .reduce((sum, t) => sum + (t.storyPoints || 0), 0); // Only show actual data for past and current dates const actualRemaining = date <= now ? totalPoints - completedByDate : totalPoints - completedByDate; return { date: dateStr, idealRemaining: Math.round(idealRemaining * 10) / 10, actualRemaining: date <= now ? Math.round(actualRemaining * 10) / 10 : (null as unknown as number), completed: date <= now ? Math.round(completedByDate * 10) / 10 : 0 }; }); // Calculate current status const completedPoints = sprint.tickets .filter((t) => t.status === 'COMPLETED') .reduce((sum, t) => sum + (t.storyPoints || 0), 0); const remainingPoints = totalPoints - completedPoints; const completedTickets = sprint.tickets.filter((t) => ['COMPLETED', 'CANCELLED', 'WONT_FIX'].includes(t.status) ).length; // Calculate if on track const daysPassed = Math.max( 0, Math.floor((now.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)) ); const expectedPointsDone = Math.min(totalPoints, pointsPerDay * daysPassed); const isOnTrack = completedPoints >= expectedPointsDone * 0.9; // Within 10% // Days remaining const daysRemaining = Math.max( 0, Math.ceil((endDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)) ); // Tickets in each status const ticketsByStatus = await prisma.devTicket.groupBy({ by: ['status'], where: { sprintId }, _count: true, _sum: { storyPoints: true } }); const statusBreakdown = Object.fromEntries( ticketsByStatus.map((s) => [ s.status, { count: s._count, points: s._sum.storyPoints || 0 }, ]) ); return successResponse({ sprint: { id: sprint.id, name: sprint.name, goal: sprint.goal, status: sprint.status, startDate: sprint.startDate, endDate: sprint.endDate, project: sprint.project }, burndown: burndownData, summary: { totalPoints, completedPoints, remainingPoints, completionPercentage: totalPoints > 0 ? Math.round((completedPoints / totalPoints) * 100) : 0, totalTickets: sprint.tickets.length, completedTickets, daysRemaining, isOnTrack, expectedPointsDone: Math.round(expectedPointsDone) }, statusBreakdown }); } export const GET = withErrorHandling(withAdmin(handleGet)); |