From 550a0e2a44e4906f2e950030f66523fff16c17b9 Mon Sep 17 00:00:00 2001 From: msogin Date: Wed, 10 Jun 2026 13:43:11 -0400 Subject: [PATCH 1/6] docs(spec): GLOOK-12 sortable dev table design --- ...26-06-10-glook-12-dev-table-sort-design.md | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-10-glook-12-dev-table-sort-design.md diff --git a/docs/superpowers/specs/2026-06-10-glook-12-dev-table-sort-design.md b/docs/superpowers/specs/2026-06-10-glook-12-dev-table-sort-design.md new file mode 100644 index 0000000..c78577a --- /dev/null +++ b/docs/superpowers/specs/2026-06-10-glook-12-dev-table-sort-design.md @@ -0,0 +1,116 @@ +# GLOOK-12: Sortable Individuals Table in Team Report + +## Goal + +Add clickable column-sort to the Individuals developer table on the team report page, with URL-persisted sort state and absolute impact rank display when sorting by a non-impact column. + +## Architecture + +Extract the inlined developer table from `src/app/report/[id]/team/page.tsx` into a new `dev-table.tsx` component that owns its own sort state — mirroring the existing `team-table.tsx` pattern. No API changes needed; all sorting is client-side over the already-fetched `developers` array. + +--- + +## Files + +| File | Change | +|---|---| +| `src/app/report/[id]/team/dev-table.tsx` | **Create** — self-contained sortable developer table | +| `src/app/report/[id]/team/page.tsx` | Remove developer table IIFE + `JiraIssuesPopover`; import `DevTable`; import `Developer` type | + +--- + +## Component: `DevTable` + +### Props + +```typescript +interface DevTableProps { + developers: Developer[]; // full server-sorted list (impact DESC) — used to build absoluteRanks + reportId: string; + filterLogins: Set; + canAct: boolean; // gates Spend column visibility +} +``` + +### Developer interface + +Moved from `page.tsx` to `dev-table.tsx` (exported so `page.tsx` can import it): + +```typescript +export interface Developer { + github_login: string; github_name: string; avatar_url: string; + total_prs: number; total_commits: number; lines_added: number; lines_removed: number; + avg_complexity: number; impact_score: number; pr_percentage: number; ai_percentage: number; + type_breakdown: Record; active_repos: string[]; + total_jira_issues?: number; + cc_total_cost?: number; + cc_requests?: number; +} +``` + +### Sort state (URL-persisted) + +```typescript +const DEV_SORT_KEYS = [ + 'name', 'total_prs', 'total_commits', 'lines_added', + 'avg_complexity', 'pr_percentage', 'ai_percentage', + 'total_jira_issues', 'cc_total_cost', 'impact_score', +] as const; +type DevSortKey = typeof DEV_SORT_KEYS[number]; +``` + +URL keys `devsort` (default: `'impact_score'`) and `devdir` (default: `'desc'`) — distinct from the Teams table's `sort`/`sortDir` to avoid collision. + +`effectiveSortKey` fallback: if `hasJira` is false and `devsort === 'total_jira_issues'`, or `hasSpend` is false and `devsort === 'cc_total_cost'`, fall back to `'impact_score'`. + +### Internal logic + +1. Compute `hasJira = developers.some(d => (d.total_jira_issues ?? 0) > 0)` +2. Compute `hasSpend = canAct && developers.some(d => Number(d.cc_total_cost ?? 0) > 0)` +3. Build `absoluteRanks: Map` from `developers` (server order = impact rank) +4. Compute `filteredDevs` from `filterLogins` +5. Sort `filteredDevs` in `useMemo` using `effectiveSortKey` and `devdir` + - `'name'` → `localeCompare` + - all other keys → numeric comparison + +### Absolute rank display + +```typescript +const showAbsolute = filterLogins.size > 0 || effectiveSortKey !== 'impact_score'; +``` + +When `showAbsolute` is true, the rank cell renders: +``` +3 (7) +``` +where `3` is the current sort rank and `(7)` is the absolute impact rank — matching the existing filtered-view behavior exactly. + +### Column headers + +Each `` becomes a ` @@ -336,6 +338,8 @@ function CommitCountWithTooltip({ const triggerRef = useRef(null); const hideTimeout = useRef | null>(null); + useEffect(() => () => { if (hideTimeout.current) clearTimeout(hideTimeout.current); }, []); + async function handleMouseEnter() { if (hideTimeout.current) clearTimeout(hideTimeout.current); if (triggerRef.current) { @@ -345,10 +349,12 @@ function CommitCountWithTooltip({ } setShow(true); const key = `${reportId}:${login}`; - if (cacheRef.current!.has(key)) { setCommits(cacheRef.current!.get(key)!); return; } + if (cacheRef.current!.has(key)) { setCommits(cacheRef.current!.get(key)!); setLoading(false); return; } setLoading(true); try { - const rows = await fetch(`/api/report/${reportId}/commits?login=${login}`).then(r => r.json()); + const res = await fetch(`/api/report/${reportId}/commits?login=${encodeURIComponent(login)}`); + if (!res.ok) throw new Error(res.statusText); + const rows = await res.json(); cacheRef.current!.set(key, rows); setCommits(rows); } catch { setCommits([]); } @@ -379,8 +385,8 @@ function CommitCountWithTooltip({ {!loading && commits && commits.length > 0 && ( - {commits.map((c: any) => ( - + {commits.map((c: any, i: number) => ( +
{c.commit_sha.slice(0, 7)} @@ -421,23 +427,27 @@ function JiraCountWithTooltip({ const [issues, setIssues] = useState(null); const [show, setShow] = useState(false); const [loading, setLoading] = useState(false); - const [pos, setPos] = useState<{ top: number; left: number; flipDown: boolean }>({ top: 0, left: 0, flipDown: false }); + const [pos, setPos] = useState<{ top: number; left: number; flipDown: boolean; bottomOffset: number }>({ top: 0, left: 0, flipDown: false, bottomOffset: 0 }); const triggerRef = useRef(null); const hideTimeout = useRef | null>(null); + useEffect(() => () => { if (hideTimeout.current) clearTimeout(hideTimeout.current); }, []); + async function handleMouseEnter() { if (hideTimeout.current) clearTimeout(hideTimeout.current); if (triggerRef.current) { const rect = triggerRef.current.getBoundingClientRect(); const flipDown = rect.top < 300; - setPos({ top: flipDown ? rect.bottom + 8 : rect.top - 8, left: rect.right, flipDown }); + setPos({ top: flipDown ? rect.bottom + 8 : rect.top - 8, left: rect.right, flipDown, bottomOffset: window.innerHeight - rect.top }); } setShow(true); const key = `jira:${reportId}:${login}`; - if (cacheRef.current!.has(key)) { setIssues(cacheRef.current!.get(key)!); return; } + if (cacheRef.current!.has(key)) { setIssues(cacheRef.current!.get(key)!); setLoading(false); return; } setLoading(true); try { - const rows = await fetch(`/api/report/${reportId}/jira-issues?login=${login}`).then(r => r.json()); + const res = await fetch(`/api/report/${reportId}/jira-issues?login=${encodeURIComponent(login)}`); + if (!res.ok) throw new Error(res.statusText); + const rows = await res.json(); cacheRef.current!.set(key, rows); setIssues(rows); } catch { setIssues([]); } @@ -453,7 +463,7 @@ function JiraCountWithTooltip({ className="fixed z-[9999] bg-gray-900 border border-gray-700 rounded-lg shadow-2xl p-3 w-80 max-h-60 overflow-y-auto text-sm" style={{ top: pos.flipDown ? pos.top : undefined, - bottom: pos.flipDown ? undefined : `${window.innerHeight - pos.top}px`, + bottom: pos.flipDown ? undefined : `${pos.bottomOffset}px`, left: Math.max(pos.left - 320, 8), }} onMouseEnter={() => { if (hideTimeout.current) clearTimeout(hideTimeout.current); }}