Open Source Team Metrics based on PRs
1"use client"
2
3import * as React from "react"
4import { IconUsers, IconUser, IconCode, IconGitPullRequest, IconMessageCircle, IconTrendingUp, IconTrendingDown, IconEye, IconClock } from "@tabler/icons-react"
5import {
6 Card,
7 CardContent,
8 CardDescription,
9 CardHeader,
10 CardTitle,
11} from "@/components/ui/card"
12import { Badge } from "@/components/ui/badge"
13import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
14import { Button } from "@/components/ui/button"
15import {
16 Select,
17 SelectContent,
18 SelectItem,
19 SelectTrigger,
20 SelectValue,
21} from "@/components/ui/select"
22import { Skeleton } from "@/components/ui/skeleton"
23import { Alert, AlertDescription } from "@/components/ui/alert"
24
25type Team = {
26 id: number;
27 organization_id: number;
28 name: string;
29 description: string | null;
30 color: string | null;
31 created_at: string;
32 updated_at: string;
33 members?: TeamMember[];
34 member_count?: number;
35};
36
37type TeamMember = {
38 id: number;
39 team_id: number;
40 user_id: string;
41 role: 'member' | 'lead' | 'admin';
42 joined_at: string;
43 user?: {
44 id: string;
45 name: string | null;
46 email: string | null;
47 image: string | null;
48 };
49};
50
51type TeamMemberStats = {
52 userId: string;
53 name: string;
54 prsCreated: number;
55 prsReviewed: number;
56 avgCycleTime: number;
57 avgPRSize: number;
58 reviewThoroughness: number;
59 contributionScore: number;
60};
61
62type TeamPerformanceMetrics = {
63 teamMembers: TeamMemberStats[];
64 totalContributors: number;
65 avgTeamCycleTime: number;
66 avgTeamPRSize: number;
67 collaborationIndex: number;
68 reviewCoverage: number;
69};
70
71
72
73export function TeamPerformanceView() {
74 const [teams, setTeams] = React.useState<Team[]>([]);
75 const [selectedTeamId, setSelectedTeamId] = React.useState<number | null>(null);
76 const [selectedTeam, setSelectedTeam] = React.useState<Team | null>(null);
77 const [teamMetrics, setTeamMetrics] = React.useState<TeamPerformanceMetrics | null>(null);
78 const [organizations, setOrganizations] = React.useState<any[]>([]);
79 const [selectedOrgId, setSelectedOrgId] = React.useState<number | null>(null);
80 const [loading, setLoading] = React.useState(true);
81 const [error, setError] = React.useState<string | null>(null);
82 const [metricsLoading, setMetricsLoading] = React.useState(false);
83 const [repositories, setRepositories] = React.useState<any[]>([]);
84
85 // Fetch organizations and repositories on mount
86 React.useEffect(() => {
87 fetchOrganizations();
88 fetchRepositories();
89 }, []);
90
91 // Fetch teams when organization changes
92 React.useEffect(() => {
93 if (selectedOrgId) {
94 fetchTeams();
95 }
96 }, [selectedOrgId]);
97
98 // Fetch team details when team changes
99 React.useEffect(() => {
100 if (selectedTeamId) {
101 fetchTeamDetails();
102 }
103 }, [selectedTeamId]);
104
105 // Fetch metrics when team details are loaded
106 React.useEffect(() => {
107 if (selectedTeam && selectedTeam.members) {
108 fetchTeamMetrics();
109 }
110 }, [selectedTeam]);
111
112 const fetchOrganizations = async () => {
113 try {
114 setLoading(true);
115 setError(null);
116
117 const response = await fetch('/api/organizations');
118
119 if (!response.ok) {
120 const errorData = await response.json();
121 throw new Error(errorData.error || 'Failed to fetch organizations');
122 }
123
124 const data = await response.json();
125 console.log('Fetched organizations:', data);
126
127 setOrganizations(data);
128
129 if (data.length > 0) {
130 setSelectedOrgId(data[0].id);
131 } else {
132 setError('No organizations found. Please ensure you have access to at least one organization.');
133 setLoading(false); // Stop loading if no organizations
134 }
135 } catch (error) {
136 console.error('Failed to fetch organizations:', error);
137 setError(error instanceof Error ? error.message : 'Failed to load organizations');
138 setLoading(false); // Stop loading on error
139 }
140 };
141
142 const fetchTeams = async () => {
143 if (!selectedOrgId) {
144 setLoading(false);
145 return;
146 }
147
148 try {
149 // Don't set loading here since fetchOrganizations already set it
150 setError(null);
151
152 console.log('Fetching teams for organization:', selectedOrgId);
153 const response = await fetch(`/api/organizations/${selectedOrgId}/teams`);
154
155 if (!response.ok) {
156 const errorData = await response.json();
157 throw new Error(errorData.error || `Failed to fetch teams (${response.status})`);
158 }
159
160 const data = await response.json();
161 console.log('Fetched teams:', data);
162
163 setTeams(data);
164
165 // Auto-select first team if available
166 if (data.length > 0 && !selectedTeamId) {
167 setSelectedTeamId(data[0].id);
168 }
169 } catch (error) {
170 console.error('Failed to fetch teams:', error);
171 setError(error instanceof Error ? error.message : 'Failed to load teams. Please check your settings.');
172 } finally {
173 setLoading(false);
174 }
175 };
176
177 const fetchRepositories = async () => {
178 try {
179 const response = await fetch('/api/repositories');
180 if (response.ok) {
181 const data = await response.json();
182 setRepositories(data.repositories || []);
183 }
184 } catch (error) {
185 console.error('Failed to fetch repositories:', error);
186 }
187 };
188
189 const fetchTeamDetails = async () => {
190 if (!selectedOrgId || !selectedTeamId) return;
191
192 try {
193 const response = await fetch(`/api/organizations/${selectedOrgId}/teams/${selectedTeamId}`);
194
195 if (response.ok) {
196 const data = await response.json();
197 setSelectedTeam(data);
198 }
199 } catch (error) {
200 console.error('Failed to fetch team details:', error);
201 }
202 };
203
204 const fetchTeamMetrics = async () => {
205 if (!selectedTeam || !selectedTeam.members) {
206 setTeamMetrics(null);
207 return;
208 }
209
210 try {
211 setMetricsLoading(true);
212 setError(null);
213
214 // Get team member user IDs
215 const teamMemberIds = selectedTeam.members.map(member => member.user_id);
216
217 // Fetch performance metrics for all repositories
218 const response = await fetch('/api/metrics/team-performance');
219
220 if (!response.ok) {
221 throw new Error(`Failed to fetch team metrics: ${response.status} ${response.statusText}`);
222 }
223
224 const data: TeamPerformanceMetrics = await response.json();
225
226 // Filter metrics to only include team members
227 const filteredTeamMembers = data.teamMembers.filter(member =>
228 teamMemberIds.includes(member.userId)
229 );
230
231 // Calculate team-level metrics for this specific team
232 const teamMetricsData: TeamPerformanceMetrics = {
233 teamMembers: filteredTeamMembers,
234 totalContributors: filteredTeamMembers.length,
235 avgTeamCycleTime: filteredTeamMembers.length > 0
236 ? filteredTeamMembers.reduce((sum, member) => sum + member.avgCycleTime, 0) / filteredTeamMembers.length
237 : 0,
238 avgTeamPRSize: filteredTeamMembers.length > 0
239 ? filteredTeamMembers.reduce((sum, member) => sum + member.avgPRSize, 0) / filteredTeamMembers.length
240 : 0,
241 collaborationIndex: filteredTeamMembers.length > 0
242 ? filteredTeamMembers.reduce((sum, member) => sum + member.prsReviewed, 0) / filteredTeamMembers.reduce((sum, member) => sum + member.prsCreated, 0) || 0
243 : 0,
244 reviewCoverage: data.reviewCoverage // Use organization-wide review coverage for context
245 };
246
247 setTeamMetrics(teamMetricsData);
248 } catch (error) {
249 console.error('Failed to fetch team metrics:', error);
250 setError(error instanceof Error ? error.message : 'Failed to load team performance data');
251 } finally {
252 setMetricsLoading(false);
253 }
254 };
255
256 const getRoleColor = (role: string) => {
257 switch (role) {
258 case 'admin':
259 return 'destructive';
260 case 'lead':
261 return 'default';
262 default:
263 return 'secondary';
264 }
265 };
266
267 const getRoleIcon = (role: string) => {
268 switch (role) {
269 case 'admin':
270 return '👑';
271 case 'lead':
272 return '⭐';
273 default:
274 return '👤';
275 }
276 };
277
278 if (loading) {
279 return (
280 <div className="space-y-6">
281 <Card>
282 <CardHeader>
283 <Skeleton className="h-8 w-48" />
284 <Skeleton className="h-4 w-64" />
285 </CardHeader>
286 <CardContent>
287 <div className="space-y-4">
288 <Skeleton className="h-32 w-full" />
289 <Skeleton className="h-32 w-full" />
290 </div>
291 </CardContent>
292 </Card>
293 </div>
294 );
295 }
296
297 if (error) {
298 return (
299 <Alert>
300 <AlertDescription>{error}</AlertDescription>
301 </Alert>
302 );
303 }
304
305 if (teams.length === 0) {
306 return (
307 <Card>
308 <CardHeader>
309 <CardTitle className="flex items-center gap-2">
310 <IconUsers className="h-5 w-5" />
311 Teams
312 </CardTitle>
313 <CardDescription>Manage and view performance metrics for your teams</CardDescription>
314 </CardHeader>
315 <CardContent>
316 <div className="text-center py-8">
317 <IconUsers className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
318 <p className="text-muted-foreground mb-4">
319 No teams have been created yet for this organization
320 </p>
321 <p className="text-sm text-muted-foreground mb-4">
322 Teams are groups of people working together. Go to Settings → Teams to create your first team and add members.
323 </p>
324 <Button
325 onClick={() => window.location.href = '/dashboard/settings'}
326 >
327 Go to Settings
328 </Button>
329 </div>
330 </CardContent>
331 </Card>
332 );
333 }
334
335 return (
336 <div className="space-y-6">
337 {/* Team Selector */}
338 <Card>
339 <CardHeader>
340 <div className="flex items-center justify-between">
341 <div>
342 <CardTitle className="flex items-center gap-2">
343 <IconUsers className="h-5 w-5" />
344 Team Performance
345 </CardTitle>
346 <CardDescription>
347 View performance metrics and contributions for your teams
348 </CardDescription>
349 </div>
350 <div className="flex items-center gap-4">
351 {organizations.length > 1 && (
352 <Select
353 value={selectedOrgId?.toString()}
354 onValueChange={(value) => setSelectedOrgId(parseInt(value))}
355 >
356 <SelectTrigger className="w-[200px]">
357 <SelectValue placeholder="Select organization" />
358 </SelectTrigger>
359 <SelectContent>
360 {organizations.map(org => (
361 <SelectItem key={org.id} value={org.id.toString()}>
362 {org.name}
363 </SelectItem>
364 ))}
365 </SelectContent>
366 </Select>
367 )}
368 <Select
369 value={selectedTeamId?.toString()}
370 onValueChange={(value) => setSelectedTeamId(parseInt(value))}
371 >
372 <SelectTrigger className="w-[200px]">
373 <SelectValue placeholder="Select a team" />
374 </SelectTrigger>
375 <SelectContent>
376 {teams.map(team => (
377 <SelectItem key={team.id} value={team.id.toString()}>
378 <div className="flex items-center gap-2">
379 {team.color && (
380 <div
381 className="w-3 h-3 rounded-full"
382 style={{ backgroundColor: team.color }}
383 />
384 )}
385 {team.name}
386 </div>
387 </SelectItem>
388 ))}
389 </SelectContent>
390 </Select>
391 </div>
392 </div>
393 </CardHeader>
394 </Card>
395
396 {/* Team Performance Metrics */}
397 {selectedTeam && teamMetrics && (
398 <>
399 {/* Team Summary Cards */}
400 <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
401 <Card>
402 <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
403 <CardTitle className="text-sm font-medium">Active Contributors</CardTitle>
404 <IconUser className="h-4 w-4 text-muted-foreground" />
405 </CardHeader>
406 <CardContent>
407 <div className="text-2xl font-bold">{teamMetrics.totalContributors}</div>
408 <p className="text-xs text-muted-foreground">
409 {selectedTeam.name} team members
410 </p>
411 </CardContent>
412 </Card>
413
414 <Card>
415 <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
416 <CardTitle className="text-sm font-medium">Avg Cycle Time</CardTitle>
417 <IconClock className="h-4 w-4 text-muted-foreground" />
418 </CardHeader>
419 <CardContent>
420 <div className="text-2xl font-bold">{Math.round(teamMetrics.avgTeamCycleTime * 10) / 10}h</div>
421 <p className="text-xs text-muted-foreground">From creation to merge</p>
422 </CardContent>
423 </Card>
424
425 <Card>
426 <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
427 <CardTitle className="text-sm font-medium">Collaboration Index</CardTitle>
428 <IconEye className="h-4 w-4 text-muted-foreground" />
429 </CardHeader>
430 <CardContent>
431 <div className="text-2xl font-bold">{Math.round(teamMetrics.collaborationIndex * 10) / 10}</div>
432 <p className="text-xs text-muted-foreground">Reviews per PR</p>
433 </CardContent>
434 </Card>
435
436 <Card>
437 <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
438 <CardTitle className="text-sm font-medium">Org Review Coverage</CardTitle>
439 <IconGitPullRequest className="h-4 w-4 text-muted-foreground" />
440 </CardHeader>
441 <CardContent>
442 <div className="text-2xl font-bold">{teamMetrics.reviewCoverage}%</div>
443 <p className="text-xs text-muted-foreground">Organization-wide</p>
444 </CardContent>
445 </Card>
446 </div>
447
448 {/* Individual Contributors */}
449 <Card>
450 <CardHeader>
451 <CardTitle>{selectedTeam.name} Contributors</CardTitle>
452 <CardDescription>
453 Performance metrics for {selectedTeam.name} team members based on their contributions across all repositories
454 </CardDescription>
455 </CardHeader>
456 <CardContent>
457 {metricsLoading ? (
458 <div className="flex items-center justify-center py-8">
459 <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
460 </div>
461 ) : teamMetrics.teamMembers.length === 0 ? (
462 <div className="text-center py-8">
463 <IconUsers className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
464 <p className="text-muted-foreground">
465 No activity found for this team's members in the last 30 days.
466 </p>
467 </div>
468 ) : (
469 <div className="space-y-4">
470 {teamMetrics.teamMembers.map((member, index) => (
471 <div key={member.userId} className="flex items-center justify-between p-4 border rounded-lg">
472 <div className="flex items-center space-x-4">
473 <div className="flex items-center space-x-2">
474 <span className="text-sm font-medium text-muted-foreground">#{index + 1}</span>
475 <Avatar className="h-8 w-8">
476 <AvatarImage src={`https://avatars.githubusercontent.com/u/${member.userId}?v=4`} />
477 <AvatarFallback>{member.name.substring(0, 2).toUpperCase()}</AvatarFallback>
478 </Avatar>
479 </div>
480 <div>
481 <p className="text-sm font-medium">{member.name}</p>
482 <p className="text-xs text-muted-foreground">
483 Contribution Score: {member.contributionScore}
484 </p>
485 </div>
486 </div>
487
488 <div className="flex items-center space-x-6 text-sm">
489 <div className="text-center">
490 <p className="font-medium">{member.prsCreated}</p>
491 <p className="text-xs text-muted-foreground">PRs Created</p>
492 </div>
493
494 <div className="text-center">
495 <p className="font-medium">{member.prsReviewed}</p>
496 <p className="text-xs text-muted-foreground">Reviews Given</p>
497 </div>
498
499 <div className="text-center">
500 <p className="font-medium">{member.avgCycleTime}h</p>
501 <p className="text-xs text-muted-foreground">Avg Cycle Time</p>
502 </div>
503
504 <div className="text-center">
505 <p className="font-medium">{member.avgPRSize}</p>
506 <p className="text-xs text-muted-foreground">Avg PR Size</p>
507 </div>
508
509 <div className="text-center">
510 <div className="flex items-center space-x-1">
511 <p className="font-medium">{member.reviewThoroughness}%</p>
512 {member.reviewThoroughness > 100 ? (
513 <IconTrendingUp className="h-3 w-3 text-green-500" />
514 ) : member.reviewThoroughness < 50 ? (
515 <IconTrendingDown className="h-3 w-3 text-orange-500" />
516 ) : null}
517 </div>
518 <p className="text-xs text-muted-foreground">Review Ratio</p>
519 </div>
520 </div>
521 </div>
522 ))}
523 </div>
524 )}
525
526 {/* Team Insights */}
527 {selectedTeam && teamMetrics.teamMembers.length > 0 && (
528 <div className="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
529 <h4 className="font-semibold text-blue-800 mb-2">Team Insights</h4>
530 <div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
531 <div>
532 <p className="text-blue-700">
533 <strong>Cross-Repository Performance:</strong> This team's metrics include contributions
534 across all repositories in your organization, providing a comprehensive view of team productivity.
535 </p>
536 </div>
537 <div>
538 <p className="text-blue-700">
539 <strong>Team Collaboration:</strong> These metrics show how well team members collaborate
540 with each other and the broader organization through code reviews and contributions.
541 </p>
542 </div>
543 </div>
544 </div>
545 )}
546 </CardContent>
547 </Card>
548 </>
549 )}
550
551 {/* Team Overview when no metrics available */}
552 {selectedTeam && !teamMetrics && !metricsLoading && (
553 <Card>
554 <CardHeader>
555 <div className="flex items-center justify-between">
556 <div>
557 <CardTitle className="flex items-center gap-2">
558 {selectedTeam.color && (
559 <div
560 className="w-4 h-4 rounded-full"
561 style={{ backgroundColor: selectedTeam.color }}
562 />
563 )}
564 {selectedTeam.name}
565 </CardTitle>
566 {selectedTeam.description && (
567 <CardDescription>{selectedTeam.description}</CardDescription>
568 )}
569 </div>
570 <Badge variant="outline">
571 {selectedTeam.members?.length || 0} members
572 </Badge>
573 </div>
574 </CardHeader>
575 <CardContent>
576 <div className="space-y-4">
577 <h3 className="text-sm font-medium">Team Members</h3>
578 <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
579 {selectedTeam.members?.map(member => (
580 <div key={member.id} className="flex items-center gap-3 p-3 border rounded-lg">
581 <Avatar>
582 <AvatarImage src={member.user?.image || undefined} />
583 <AvatarFallback>
584 {member.user?.name?.charAt(0) || 'U'}
585 </AvatarFallback>
586 </Avatar>
587 <div className="flex-1">
588 <p className="font-medium text-sm">{member.user?.name || 'Unknown'}</p>
589 <p className="text-xs text-muted-foreground">{member.user?.email}</p>
590 </div>
591 <Badge variant={getRoleColor(member.role)}>
592 {getRoleIcon(member.role)} {member.role}
593 </Badge>
594 </div>
595 ))}
596 </div>
597 <div className="mt-4 p-4 bg-orange-50 border border-orange-200 rounded-lg">
598 <p className="text-orange-800 text-sm">
599 No performance data available for this team yet. Team members need to create pull requests
600 and reviews to generate performance metrics.
601 </p>
602 </div>
603 </div>
604 </CardContent>
605 </Card>
606 )}
607 </div>
608 );
609}