Open Source Team Metrics based on PRs
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 609 lines 23 kB view raw
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}