forked from cardosofelipe/fast-next-template
- Refactored JSX elements to improve readability by collapsing multi-line props and attributes into single lines if their length permits. - Improved consistency in component imports by grouping and consolidating them. - No functional changes, purely restructuring for clarity and maintainability.
239 lines
7.5 KiB
TypeScript
239 lines
7.5 KiB
TypeScript
/**
|
|
* Sprint Progress Component
|
|
*
|
|
* Displays sprint overview with progress bar, issue stats, and burndown chart.
|
|
*/
|
|
|
|
'use client';
|
|
|
|
import { TrendingUp, Calendar } from 'lucide-react';
|
|
import { format } from 'date-fns';
|
|
import { cn } from '@/lib/utils';
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { ProgressBar } from './ProgressBar';
|
|
import { BurndownChart } from './BurndownChart';
|
|
import type { Sprint, BurndownDataPoint } from './types';
|
|
|
|
// ============================================================================
|
|
// Types
|
|
// ============================================================================
|
|
|
|
interface SprintProgressProps {
|
|
/** Current sprint data */
|
|
sprint: Sprint | null;
|
|
/** Burndown chart data */
|
|
burndownData?: BurndownDataPoint[];
|
|
/** List of available sprints for selector */
|
|
availableSprints?: { id: string; name: string }[];
|
|
/** Currently selected sprint ID */
|
|
selectedSprintId?: string;
|
|
/** Callback when sprint selection changes */
|
|
onSprintChange?: (sprintId: string) => void;
|
|
/** Whether data is loading */
|
|
isLoading?: boolean;
|
|
/** Additional CSS classes */
|
|
className?: string;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Helper Functions
|
|
// ============================================================================
|
|
|
|
function formatSprintDates(startDate?: string, endDate?: string): string {
|
|
if (!startDate || !endDate) return 'Dates not set';
|
|
try {
|
|
const start = format(new Date(startDate), 'MMM d');
|
|
const end = format(new Date(endDate), 'MMM d, yyyy');
|
|
return `${start} - ${end}`;
|
|
} catch {
|
|
return 'Invalid dates';
|
|
}
|
|
}
|
|
|
|
function calculateProgress(sprint: Sprint): number {
|
|
if (sprint.total_issues === 0) return 0;
|
|
return Math.round((sprint.completed_issues / sprint.total_issues) * 100);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Subcomponents
|
|
// ============================================================================
|
|
|
|
interface StatCardProps {
|
|
value: number;
|
|
label: string;
|
|
colorClass: string;
|
|
}
|
|
|
|
function StatCard({ value, label, colorClass }: StatCardProps) {
|
|
return (
|
|
<div className="rounded-lg border p-3 text-center">
|
|
<div className={cn('text-2xl font-bold', colorClass)}>{value}</div>
|
|
<div className="text-xs text-muted-foreground">{label}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SprintProgressSkeleton() {
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<div className="space-y-2">
|
|
<Skeleton className="h-5 w-40" />
|
|
<Skeleton className="h-4 w-56" />
|
|
</div>
|
|
<Skeleton className="h-9 w-32" />
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-6">
|
|
{/* Progress bar skeleton */}
|
|
<div className="space-y-2">
|
|
<div className="flex justify-between">
|
|
<Skeleton className="h-4 w-24" />
|
|
<Skeleton className="h-4 w-12" />
|
|
</div>
|
|
<Skeleton className="h-2 w-full rounded-full" />
|
|
</div>
|
|
|
|
{/* Stats grid skeleton */}
|
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
|
{[1, 2, 3, 4].map((i) => (
|
|
<div key={i} className="rounded-lg border p-3 text-center">
|
|
<Skeleton className="mx-auto h-8 w-8" />
|
|
<Skeleton className="mx-auto mt-1 h-3 w-16" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Burndown skeleton */}
|
|
<div className="space-y-2">
|
|
<Skeleton className="h-4 w-28" />
|
|
<Skeleton className="h-32 w-full" />
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Main Component
|
|
// ============================================================================
|
|
|
|
export function SprintProgress({
|
|
sprint,
|
|
burndownData = [],
|
|
availableSprints = [],
|
|
selectedSprintId,
|
|
onSprintChange,
|
|
isLoading = false,
|
|
className,
|
|
}: SprintProgressProps) {
|
|
if (isLoading) {
|
|
return <SprintProgressSkeleton />;
|
|
}
|
|
|
|
if (!sprint) {
|
|
return (
|
|
<Card className={className} data-testid="sprint-progress">
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<TrendingUp className="h-5 w-5" aria-hidden="true" />
|
|
Sprint Overview
|
|
</CardTitle>
|
|
<CardDescription>No active sprint</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="flex flex-col items-center justify-center py-8 text-center text-muted-foreground">
|
|
<Calendar className="mb-2 h-8 w-8" aria-hidden="true" />
|
|
<p className="text-sm">No sprint is currently active</p>
|
|
<p className="mt-1 text-xs">Create a sprint to track progress</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
const progress = calculateProgress(sprint);
|
|
const dateRange = formatSprintDates(sprint.start_date, sprint.end_date);
|
|
|
|
return (
|
|
<Card className={className} data-testid="sprint-progress">
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<TrendingUp className="h-5 w-5" aria-hidden="true" />
|
|
Sprint Overview
|
|
</CardTitle>
|
|
<CardDescription>
|
|
{sprint.name} ({dateRange})
|
|
</CardDescription>
|
|
</div>
|
|
|
|
{availableSprints.length > 1 && onSprintChange && (
|
|
<Select value={selectedSprintId || sprint.id} onValueChange={onSprintChange}>
|
|
<SelectTrigger className="w-32" aria-label="Select sprint">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{availableSprints.map((s) => (
|
|
<SelectItem key={s.id} value={s.id}>
|
|
{s.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-6">
|
|
{/* Sprint Progress */}
|
|
<ProgressBar
|
|
value={progress}
|
|
showLabel
|
|
aria-label={`Sprint progress: ${progress}% complete`}
|
|
/>
|
|
|
|
{/* Issue Stats Grid */}
|
|
<div
|
|
className="grid grid-cols-2 gap-4 sm:grid-cols-4"
|
|
role="list"
|
|
aria-label="Sprint issue statistics"
|
|
>
|
|
<StatCard
|
|
value={sprint.completed_issues}
|
|
label="Completed"
|
|
colorClass="text-green-600"
|
|
/>
|
|
<StatCard
|
|
value={sprint.in_progress_issues}
|
|
label="In Progress"
|
|
colorClass="text-blue-600"
|
|
/>
|
|
<StatCard value={sprint.blocked_issues} label="Blocked" colorClass="text-red-600" />
|
|
<StatCard value={sprint.todo_issues} label="To Do" colorClass="text-gray-600" />
|
|
</div>
|
|
|
|
{/* Burndown Chart */}
|
|
<div>
|
|
<h4 className="mb-2 text-sm font-medium">Burndown Chart</h4>
|
|
<BurndownChart data={burndownData} height={120} />
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|