Open Source Team Metrics based on PRs
1"use client"
2
3import * as React from "react"
4import { useTeamFilterParams } from "@/hooks/use-team-filter"
5import { LineChart, Line, ResponsiveContainer, Tooltip } from "recharts"
6import { IconArrowUpRight, IconArrowDownRight } from "@tabler/icons-react"
7
8import {
9 Card,
10 CardContent,
11 CardHeader,
12 CardTitle,
13} from "@/components/ui/card"
14
15type TimeSeriesDataPoint = {
16 date: string;
17 prThroughput: number;
18 cycleTime: number;
19 reviewTime: number;
20 codingHours: number;
21};
22
23interface EnhancedCompactEngineeringMetricsProps {
24 initialData?: TimeSeriesDataPoint[];
25 className?: string;
26}
27
28export function EnhancedCompactEngineeringMetrics({
29 initialData,
30 className
31}: EnhancedCompactEngineeringMetricsProps) {
32 const [chartData, setChartData] = React.useState<TimeSeriesDataPoint[]>(initialData || []);
33 const [loading, setLoading] = React.useState(!initialData);
34 const [error, setError] = React.useState<string | null>(null);
35 const teamFilterParams = useTeamFilterParams();
36
37 // Background refresh after initial load
38 React.useEffect(() => {
39 if (!initialData) return;
40
41 // Delay background refresh by 2 seconds to not interfere with initial render
42 const refreshTimer = setTimeout(async () => {
43 try {
44 const url = `/api/metrics/time-series?${teamFilterParams}`;
45 const response = await fetch(url);
46 if (response.ok) {
47 const data = await response.json();
48 setChartData(data);
49 }
50 } catch (error) {
51 // Silent fail on background refresh - we have initial data
52 console.warn("Background refresh failed:", error);
53 }
54 }, 2000);
55
56 return () => clearTimeout(refreshTimer);
57 }, [initialData, teamFilterParams]);
58
59 React.useEffect(() => {
60 if (initialData) return; // Skip if we have initial data
61
62 const fetchData = async () => {
63 try {
64 setLoading(true);
65 setError(null);
66
67 const url = `/api/metrics/time-series?${teamFilterParams}`;
68 const response = await fetch(url);
69
70 if (!response.ok) {
71 throw new Error(`Failed to fetch metrics: ${response.status} ${response.statusText}`);
72 }
73
74 const data = await response.json();
75 setChartData(data);
76 } catch (error) {
77 console.error("Failed to load time series data:", error);
78 setError(error instanceof Error ? error.message : "An unknown error occurred");
79 } finally {
80 setLoading(false);
81 }
82 };
83
84 fetchData();
85 }, [initialData, teamFilterParams]);
86
87 const { filteredData, metrics } = React.useMemo(() => {
88 if (!chartData.length) return {
89 filteredData: [],
90 metrics: {
91 prCurrent: 0, prChange: 0,
92 cycleCurrent: 0, cycleChange: 0,
93 reviewCurrent: 0, reviewChange: 0,
94 codingCurrent: 0, codingChange: 0
95 }
96 };
97
98 // Sort data by date in ascending order
99 const sortedData = [...chartData].sort((a, b) =>
100 new Date(a.date).getTime() - new Date(b.date).getTime()
101 );
102
103 const filtered = sortedData;
104
105 // Calculate metrics
106 const latest = filtered[filtered.length - 1] || { prThroughput: 0, cycleTime: 0, reviewTime: 0, codingHours: 0 };
107 const previous = filtered[filtered.length - 2] || { prThroughput: 0, cycleTime: 0, reviewTime: 0, codingHours: 0 };
108
109 const prCurrent = latest.prThroughput;
110 const prChange = previous.prThroughput === 0 ? 0 :
111 ((prCurrent - previous.prThroughput) / previous.prThroughput) * 100;
112
113 const cycleCurrent = latest.cycleTime;
114 const cycleChange = previous.cycleTime === 0 ? 0 :
115 ((cycleCurrent - previous.cycleTime) / previous.cycleTime) * 100;
116
117 const reviewCurrent = latest.reviewTime;
118 const reviewChange = previous.reviewTime === 0 ? 0 :
119 ((reviewCurrent - previous.reviewTime) / previous.reviewTime) * 100;
120
121 const codingCurrent = latest.codingHours;
122 const codingChange = previous.codingHours === 0 ? 0 :
123 ((codingCurrent - previous.codingHours) / previous.codingHours) * 100;
124
125 return {
126 filteredData: filtered,
127 metrics: {
128 prCurrent,
129 prChange: isNaN(prChange) ? 0 : prChange,
130 cycleCurrent,
131 cycleChange: isNaN(cycleChange) ? 0 : cycleChange,
132 reviewCurrent,
133 reviewChange: isNaN(reviewChange) ? 0 : reviewChange,
134 codingCurrent,
135 codingChange: isNaN(codingChange) ? 0 : codingChange
136 }
137 };
138 }, [chartData]);
139
140 const metricsConfig = [
141 {
142 name: "Shipping Velocity",
143 value: metrics.prCurrent,
144 change: metrics.prChange,
145 dataKey: "prThroughput",
146 color: "#3b82f6",
147 unit: "",
148 isReversed: false
149 },
150 {
151 name: "Delivery Speed",
152 value: metrics.cycleCurrent,
153 change: metrics.cycleChange,
154 dataKey: "cycleTime",
155 color: "#f97316",
156 unit: "hrs",
157 isReversed: true
158 },
159 {
160 name: "Feedback Time",
161 value: metrics.reviewCurrent,
162 change: metrics.reviewChange,
163 dataKey: "reviewTime",
164 color: "#a855f7",
165 unit: "hrs",
166 isReversed: true
167 },
168 {
169 name: "Flow State",
170 value: metrics.codingCurrent,
171 change: metrics.codingChange,
172 dataKey: "codingHours",
173 color: "#10b981",
174 unit: "hrs",
175 isReversed: false
176 }
177 ];
178
179 if (loading) {
180 return (
181 <Card className={`@container/card bg-gradient-to-t from-gray-50/30 to-white dark:from-card/10 dark:to-card shadow-xs ${className}`}>
182 <CardHeader className="pb-2">
183 <CardTitle className="text-base">Team Flow Metrics</CardTitle>
184 </CardHeader>
185 <CardContent className="h-[220px] flex items-center justify-center">
186 <div className="animate-pulse w-full h-2/3 bg-muted rounded"></div>
187 </CardContent>
188 </Card>
189 );
190 }
191
192 if (error) {
193 return (
194 <Card className={`@container/card bg-gradient-to-t from-gray-50/30 to-white dark:from-card/10 dark:to-card shadow-xs ${className}`}>
195 <CardHeader className="pb-2">
196 <CardTitle className="text-base">Team Flow Metrics</CardTitle>
197 </CardHeader>
198 <CardContent>
199 <p className="text-red-500">{error}</p>
200 <button
201 onClick={() => window.location.reload()}
202 className="mt-4 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
203 >
204 Retry
205 </button>
206 </CardContent>
207 </Card>
208 );
209 }
210
211 return (
212 <Card className={`@container/card bg-gradient-to-t from-gray-50/30 to-white dark:from-card/10 dark:to-card shadow-xs ${className}`}>
213 <CardHeader className="pb-0">
214 <CardTitle className="text-base flex items-center justify-between">
215 Team Flow Metrics
216 {initialData && (
217 <span className="text-xs text-muted-foreground font-normal">
218 ⚡ Server-enhanced
219 </span>
220 )}
221 </CardTitle>
222 </CardHeader>
223 <CardContent className="pt-2">
224 <div className="space-y-4">
225 {metricsConfig.map((metric) => (
226 <div key={metric.name} className="flex items-start space-x-4">
227 <div className="w-[140px] pt-1">
228 <div className="space-y-1">
229 <div className="text-xs font-medium text-muted-foreground">{metric.name}</div>
230 <div className="flex items-center">
231 <span className="text-sm font-bold mr-1">{metric.value.toFixed(1)}{metric.unit}</span>
232 <span className={`text-xs font-medium ${
233 metric.isReversed ?
234 (metric.change < 0 ? 'text-green-500' : metric.change > 0 ? 'text-orange-500' : 'text-muted-foreground') :
235 (metric.change > 0 ? 'text-green-500' : metric.change < 0 ? 'text-orange-500' : 'text-muted-foreground')
236 }`}>
237 {metric.isReversed ?
238 (metric.change < 0 ? <IconArrowDownRight className="h-3 w-3 mr-0.5" /> : metric.change > 0 ? <IconArrowUpRight className="h-3 w-3 mr-0.5" /> : null) :
239 (metric.change > 0 ? <IconArrowUpRight className="h-3 w-3 mr-0.5" /> : metric.change < 0 ? <IconArrowDownRight className="h-3 w-3 mr-0.5" /> : null)
240 }
241 {Math.abs(metric.change).toFixed(1)}%
242 </span>
243 </div>
244 </div>
245 </div>
246 <div className="flex-1 h-[40px]">
247 <ResponsiveContainer width="100%" height="100%">
248 <LineChart data={filteredData}>
249 <Line
250 type="monotone"
251 dataKey={metric.dataKey}
252 stroke={metric.color}
253 strokeWidth={3.5}
254 dot={false}
255 activeDot={{ r: 6, strokeWidth: 0 }}
256 />
257 <Tooltip
258 formatter={(value) => [`${value}${metric.unit}`, metric.name]}
259 labelFormatter={(label) => {
260 const date = new Date(label);
261 return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
262 }}
263 contentStyle={{ fontSize: '12px' }}
264 />
265 </LineChart>
266 </ResponsiveContainer>
267 </div>
268 </div>
269 ))}
270 </div>
271 </CardContent>
272 </Card>
273 );
274}