diff --git a/components/burnout/BurnoutRiskTable.test.tsx b/components/burnout/BurnoutRiskTable.test.tsx new file mode 100644 index 000000000..3728baf37 --- /dev/null +++ b/components/burnout/BurnoutRiskTable.test.tsx @@ -0,0 +1,114 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import BurnoutRiskTable from './BurnoutRiskTable'; + +const mockContributors = [ + { + username: 'alice', + avatarUrl: 'https://github.com/alice.png', + totalCommits: 500, + commitShare: 50, + burnoutScore: 85, + riskLevel: 'High' as const, + activeWeeks: 12, + highIntensityWeeks: 6, + consecutiveHighWeeks: 4, + restWeeks: 0, + recentTrend: [10, 20, 30], + }, + { + username: 'bob', + avatarUrl: 'https://github.com/bob.png', + totalCommits: 300, + commitShare: 30, + burnoutScore: 60, + riskLevel: 'Medium' as const, + activeWeeks: 8, + highIntensityWeeks: 2, + consecutiveHighWeeks: 1, + restWeeks: 2, + recentTrend: [5, 10, 15], + }, + { + username: 'charlie', + avatarUrl: 'https://github.com/charlie.png', + totalCommits: 200, + commitShare: 20, + burnoutScore: 20, + riskLevel: 'Low' as const, + activeWeeks: 4, + highIntensityWeeks: 0, + consecutiveHighWeeks: 0, + restWeeks: 6, + recentTrend: [2, 4, 6], + }, +]; + +describe('BurnoutRiskTable Live Search & Column Sorting', () => { + beforeEach(() => { + // cleanup + }); + + it('renders all contributors initially', () => { + render(); + expect(screen.getByText('@alice')).toBeInTheDocument(); + expect(screen.getByText('@bob')).toBeInTheDocument(); + expect(screen.getByText('@charlie')).toBeInTheDocument(); + expect(screen.getByText(/Showing 3 of 3/i)).toBeInTheDocument(); + }); + + it('filters contributors dynamically by search query', () => { + render(); + const searchInput = screen.getByPlaceholderText(/search contributor/i); + + fireEvent.change(searchInput, { target: { value: 'bob' } }); + + expect(screen.getByText('@bob')).toBeInTheDocument(); + expect(screen.queryByText('@alice')).not.toBeInTheDocument(); + expect(screen.queryByText('@charlie')).not.toBeInTheDocument(); + expect(screen.getByText(/Showing 1 of 3/i)).toBeInTheDocument(); + }); + + it('filters contributors to high risk only when toggle is enabled', () => { + render(); + const toggleBtn = screen.getByRole('button', { name: /high risk only/i }); + + fireEvent.click(toggleBtn); + + expect(screen.getByText('@alice')).toBeInTheDocument(); + expect(screen.queryByText('@bob')).not.toBeInTheDocument(); + expect(screen.queryByText('@charlie')).not.toBeInTheDocument(); + expect(screen.getByText(/Showing 1 of 3/i)).toBeInTheDocument(); + }); + + it('sorts contributors by username when Contributor header is clicked', () => { + render(); + const contributorHeaderBtn = screen.getByRole('button', { name: /contributor/i }); + + // Initial default sort is burnoutScore desc (alice, bob, charlie) + fireEvent.click(contributorHeaderBtn); // Sort by username desc (charlie, bob, alice) + + const namesDesc = screen.getAllByText(/@(alice|bob|charlie)/i).map((el) => el.textContent); + expect(namesDesc).toEqual(['@charlie', '@bob', '@alice']); + + fireEvent.click(contributorHeaderBtn); // Toggle to asc (alice, bob, charlie) + const namesAsc = screen.getAllByText(/@(alice|bob|charlie)/i).map((el) => el.textContent); + expect(namesAsc).toEqual(['@alice', '@bob', '@charlie']); + }); + + it('shows empty state and resets filters when Reset Filters button is clicked', () => { + render(); + const searchInput = screen.getByPlaceholderText(/search contributor/i); + + fireEvent.change(searchInput, { target: { value: 'nonexistent_user' } }); + + expect(screen.getByText(/No contributors match your filters/i)).toBeInTheDocument(); + + const resetBtn = screen.getByRole('button', { name: /reset filters/i }); + fireEvent.click(resetBtn); + + expect(screen.getByText('@alice')).toBeInTheDocument(); + expect(screen.getByText('@bob')).toBeInTheDocument(); + expect(screen.getByText('@charlie')).toBeInTheDocument(); + }); +}); diff --git a/components/burnout/BurnoutRiskTable.tsx b/components/burnout/BurnoutRiskTable.tsx index 514b58ee7..41273c9e7 100644 --- a/components/burnout/BurnoutRiskTable.tsx +++ b/components/burnout/BurnoutRiskTable.tsx @@ -1,8 +1,18 @@ 'use client'; +import { useState, useMemo } from 'react'; import { motion } from 'framer-motion'; -import { Flame, ShieldAlert, Sparkles } from 'lucide-react'; -import Image from 'next/image'; +import { + Flame, + ShieldAlert, + Sparkles, + Search, + X, + ArrowUpDown, + ArrowUp, + ArrowDown, + Filter, +} from 'lucide-react'; interface ContributorMetric { username: string; @@ -22,6 +32,15 @@ interface BurnoutRiskTableProps { contributors: ContributorMetric[]; } +type SortColumn = + | 'username' + | 'commitShare' + | 'highIntensityWeeks' + | 'restWeeks' + | 'burnoutScore' + | 'totalCommits'; +type SortDirection = 'asc' | 'desc'; + // Custom Pure SVG Sparkline for visual performance function Sparkline({ data }: { data: number[] }) { if (!data || data.length === 0) return null; @@ -71,6 +90,11 @@ function Sparkline({ data }: { data: number[] }) { } export default function BurnoutRiskTable({ contributors }: BurnoutRiskTableProps) { + const [searchQuery, setSearchQuery] = useState(''); + const [highRiskOnly, setHighRiskOnly] = useState(false); + const [sortColumn, setSortColumn] = useState('burnoutScore'); + const [sortDirection, setSortDirection] = useState('desc'); + const getBadgeStyle = (level: 'Low' | 'Medium' | 'High') => { switch (level) { case 'High': @@ -82,6 +106,82 @@ export default function BurnoutRiskTable({ contributors }: BurnoutRiskTableProps } }; + const handleSort = (column: SortColumn) => { + if (sortColumn === column) { + setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc'); + } else { + setSortColumn(column); + setSortDirection('desc'); + } + }; + + const filteredAndSortedContributors = useMemo(() => { + return contributors + .filter((c) => { + const matchesSearch = c.username.toLowerCase().includes(searchQuery.toLowerCase().trim()); + const matchesHighRisk = !highRiskOnly || c.riskLevel === 'High'; + return matchesSearch && matchesHighRisk; + }) + .sort((a, b) => { + let valA: number | string = 0; + let valB: number | string = 0; + + switch (sortColumn) { + case 'username': + valA = a.username.toLowerCase(); + valB = b.username.toLowerCase(); + break; + case 'totalCommits': + valA = a.totalCommits; + valB = b.totalCommits; + break; + case 'commitShare': + valA = a.commitShare; + valB = b.commitShare; + break; + case 'highIntensityWeeks': + valA = a.highIntensityWeeks; + valB = b.highIntensityWeeks; + break; + case 'restWeeks': + valA = a.restWeeks; + valB = b.restWeeks; + break; + case 'burnoutScore': + default: + valA = a.burnoutScore; + valB = b.burnoutScore; + break; + } + + if (typeof valA === 'string' && typeof valB === 'string') { + return sortDirection === 'asc' ? valA.localeCompare(valB) : valB.localeCompare(valA); + } + + return sortDirection === 'asc' + ? (valA as number) - (valB as number) + : (valB as number) - (valA as number); + }); + }, [contributors, searchQuery, highRiskOnly, sortColumn, sortDirection]); + + const renderSortIcon = (column: SortColumn) => { + if (sortColumn !== column) { + return ( + + ); + } + return sortDirection === 'asc' ? ( + + ) : ( + + ); + }; + + const highRiskCount = useMemo( + () => contributors.filter((c) => c.riskLevel === 'High').length, + [contributors] + ); + return ( -
- -

- Contributor Workload & Burnout Risks -

+ {/* Header & Title */} +
+
+ +

+ Contributor Workload & Burnout Risks +

+ + Showing {filteredAndSortedContributors.length} of {contributors.length} + +
+ + {/* Search & High Risk Toggle Controls */} +
+
+ setSearchQuery(e.target.value)} + className="w-full pl-8 pr-7 py-1.5 rounded-xl border border-black/10 dark:border-white/10 bg-white/80 dark:bg-[#121212]/80 text-xs text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-indigo-500" + /> + + {searchQuery && ( + + )} +
+ + +
+ {/* Table Container */}
- - - + + + + + - - - + + + + + + + - {contributors.map((c, i) => ( + {filteredAndSortedContributors.map((c, i) => ( {/* Contributor Profile */} @@ -188,6 +378,28 @@ export default function BurnoutRiskTable({ contributors }: BurnoutRiskTableProps ))}
ContributorWorkload Share
+ + + + Weekly Activity (12w)Intensity WeeksRest WeeksBurnout Risk + + + + + +
+ + {/* Empty State */} + {filteredAndSortedContributors.length === 0 && ( +
+ +

+ No contributors match your filters +

+

+ Try adjusting your search terms or toggling off "High Risk Only". +

+ +
+ )}
);