diff --git a/docs/superpowers/plans/2026-06-10-glook-12-dev-table-sort.md b/docs/superpowers/plans/2026-06-10-glook-12-dev-table-sort.md new file mode 100644 index 0000000..711b558 --- /dev/null +++ b/docs/superpowers/plans/2026-06-10-glook-12-dev-table-sort.md @@ -0,0 +1,709 @@ +# GLOOK-12: Sortable Individuals Table + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add URL-persisted clickable column sort to the Individuals developer table on the team report page, with absolute impact rank displayed when sorted by a non-impact column. + +**Architecture:** Extract the inlined developer table IIFE from `page.tsx` into a new `dev-table.tsx` component (mirroring the existing `team-table.tsx` pattern). The new component owns `devsort`/`devdir` URL state, computes sort order client-side, and displays absolute impact rank when the active sort key is not `impact_score`. Helper subcomponents (`CommitCountWithTooltip`, `JiraCountWithTooltip`, badge components, `TypeBreakdown`) move to the new file. + +**Tech Stack:** TypeScript, Next.js 15 (`'use client'`), `useUrlState` from `@/lib/url-state`, `useMemo`, `useRef`, `createPortal` + +--- + +## File Map + +| File | Change | +|---|---| +| `src/app/report/[id]/team/dev-table.tsx` | **Create** — full sortable developer table component | +| `src/app/report/[id]/team/page.tsx` | Remove IIFE + helper subcomponents; import and use `` | + +--- + +### Task 1: Create `dev-table.tsx` + +**Files:** +- Create: `src/app/report/[id]/team/dev-table.tsx` + +**Context:** The developer table currently lives as an IIFE inside `page.tsx` (lines 403–523). `CommitCountWithTooltip` (lines 702–802), `JiraCountWithTooltip` (lines 804–869), and the badge/breakdown helpers (lines 635–700) also live in `page.tsx`. All of these move to the new file. `TYPE_COLORS` (line 52) also moves. + +The new component: +- Exports `Developer` interface (so `page.tsx` can import it) +- Owns `devsort`/`devdir` URL state (keys distinct from TeamTable's `sort`/`dir`) +- Default sort: `impact_score` DESC (matches server order) +- `showAbsolute`: true when `filterLogins.size > 0 || effectiveSortKey !== 'impact_score'` +- `absoluteRanks` built from the full `developers` array (server = impact order) + +- [ ] **Step 1: Create the file** + +Create `src/app/report/[id]/team/dev-table.tsx` with this complete content: + +```tsx +'use client'; + +import { useState, useRef, useMemo } from 'react'; +import { useRouter } from 'next/navigation'; +import { createPortal } from 'react-dom'; +import { useUrlState } from '@/lib/url-state'; + +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; + total_jira_issues?: number; + cc_total_cost?: number; + cc_requests?: number; + type_breakdown: Record; + active_repos: string[]; +} + +const TYPE_COLORS: Record = { + feature: 'bg-blue-500', + bug: 'bg-red-500', + refactor: 'bg-purple-500', + infra: 'bg-yellow-500', + docs: 'bg-gray-500', + test: 'bg-green-500', + other: 'bg-gray-600', +}; + +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]; + +interface DevTableProps { + developers: Developer[]; // full list, server-sorted by impact DESC + reportId: string; + org: string; // needed for commit URL links in tooltip + filterLogins: Set; + canAct: boolean; // gates Spend column +} + +export default function DevTable({ developers, reportId, org, filterLogins, canAct }: DevTableProps) { + const router = useRouter(); + const commitCache = useRef>(new Map()); + const jiraCache = useRef>(new Map()); + + const hasJira = developers.some(d => (d.total_jira_issues ?? 0) > 0); + const hasSpend = canAct && developers.some(d => Number(d.cc_total_cost ?? 0) > 0); + + const [sortKey, setSortKey] = useUrlState({ + key: 'devsort', + type: 'enum', + values: DEV_SORT_KEYS, + default: 'impact_score', + history: 'replace', + }); + const [sortDir, setSortDir] = useUrlState<'asc' | 'desc'>({ + key: 'devdir', + type: 'enum', + values: ['asc', 'desc'] as const, + default: 'desc', + history: 'replace', + }); + + // Fall back to impact_score if the active sort key's column is hidden + const effectiveSortKey: DevSortKey = + (!hasJira && sortKey === 'total_jira_issues') || (!hasSpend && sortKey === 'cc_total_cost') + ? 'impact_score' + : sortKey; + + // Build impact rank from server-sorted full list (impact order) + const absoluteRanks = useMemo(() => { + const map = new Map(); + developers.forEach((d, idx) => map.set(d.github_login, idx + 1)); + return map; + }, [developers]); + + const filteredDevs = useMemo( + () => filterLogins.size > 0 + ? developers.filter(d => filterLogins.has(d.github_login)) + : developers, + [developers, filterLogins], + ); + + const sortedDevs = useMemo(() => { + const sign = sortDir === 'asc' ? 1 : -1; + return [...filteredDevs].sort((a, b) => { + if (effectiveSortKey === 'name') { + return (a.github_name || a.github_login).localeCompare(b.github_name || b.github_login) * sign; + } + const av = Number(a[effectiveSortKey as keyof Developer] ?? 0); + const bv = Number(b[effectiveSortKey as keyof Developer] ?? 0); + if (av === bv) return (a.github_name || a.github_login).localeCompare(b.github_name || b.github_login); + return (av - bv) * sign; + }); + }, [filteredDevs, effectiveSortKey, sortDir]); + + const onSort = (key: DevSortKey) => { + if (sortKey === key) { + setSortDir(sortDir === 'desc' ? 'asc' : 'desc'); + } else { + setSortKey(key); + setSortDir(key === 'name' ? 'asc' : 'desc'); + } + }; + + const sortCaret = (key: DevSortKey) => + effectiveSortKey === key ? (sortDir === 'desc' ? ' ▼' : ' ▲') : ''; + + // Show absolute impact rank when sort diverges from impact order, or filter is active + const showAbsolute = filterLogins.size > 0 || effectiveSortKey !== 'impact_score'; + + if (sortedDevs.length === 0) return null; + + return ( +
+ + + + + + + + + + + {hasJira && ( + + )} + {hasSpend && ( + + )} + + + + + + {sortedDevs.map((dev, i) => ( + router.push(`/report/${reportId}/dev/${dev.github_login}`)} + > + + + + + + + + {hasJira && ( + + )} + {hasSpend && ( + + )} + + + + ))} + +
+ + + + + + + + + + + + + + + + + + Types + +
+
+ + {i + 1} + {showAbsolute && absoluteRanks.has(dev.github_login) && ( + ({absoluteRanks.get(dev.github_login)}) + )} + + {dev.avatar_url && ( + + )} +
+
{dev.github_name || dev.github_login}
+
@{dev.github_login}
+
+
+
{dev.total_prs} + + + +{dev.lines_added.toLocaleString()} + / + -{dev.lines_removed.toLocaleString()} + + + + + + + e.stopPropagation()}> + {(dev.total_jira_issues ?? 0) > 0 ? ( + + ) : ( + + )} + + {Number(dev.cc_total_cost ?? 0) > 0 ? ( + + ${(Number(dev.cc_total_cost) / 100).toFixed(2)} + + ) : ( + + )} + + + + +
+
+ ); +} + +function ComplexityBadge({ value }: { value: number }) { + const n = Number(value) || 0; + const color = n >= 7 ? 'text-red-400' : n >= 4 ? 'text-yellow-400' : 'text-green-400'; + return {n.toFixed(1)}; +} + +function ImpactBadge({ value }: { value: number }) { + const n = Number(value) || 0; + const color = n >= 7 ? 'bg-accent-light' : n >= 4 ? 'bg-accent-dark' : 'bg-gray-700'; + return ( + + {n.toFixed(1)} + + ); +} + +function AiPercentBadge({ value }: { value: number }) { + const n = Number(value) || 0; + if (n === 0) return ; + const color = n >= 50 ? 'text-purple-400' : 'text-purple-600'; + return {n}%; +} + +function PrPercentBadge({ value }: { value: number }) { + const n = Number(value) || 0; + const color = n >= 80 ? 'text-green-400' : n >= 50 ? 'text-yellow-400' : 'text-red-400'; + return {n}%; +} + +function TypeBreakdown({ breakdown }: { breakdown: Record }) { + const entries = Object.entries(breakdown || {}).sort((a, b) => b[1] - a[1]); + return ( +
+ {entries.map(([type, count]) => ( + + {type} {count} + + ))} +
+ ); +} + +function CommitCountWithTooltip({ + count, reportId, login, org, cacheRef, +}: { + count: number; reportId: string; login: string; org: string; + cacheRef: React.RefObject>; +}) { + const [commits, setCommits] = 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 triggerRef = useRef(null); + const hideTimeout = useRef | null>(null); + + 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 }); + } + setShow(true); + const key = `${reportId}:${login}`; + if (cacheRef.current!.has(key)) { setCommits(cacheRef.current!.get(key)!); return; } + setLoading(true); + try { + const rows = await fetch(`/api/report/${reportId}/commits?login=${login}`).then(r => r.json()); + cacheRef.current!.set(key, rows); + setCommits(rows); + } catch { setCommits([]); } + setLoading(false); + } + + function handleMouseLeave() { + hideTimeout.current = setTimeout(() => setShow(false), 200); + } + + const tooltip = show && typeof document !== 'undefined' ? createPortal( +
{ if (hideTimeout.current) clearTimeout(hideTimeout.current); }} + onMouseLeave={handleMouseLeave} + > +
+ {count} commits by @{login} +
+
+ {loading &&

Loading...

} + {!loading && commits && commits.length === 0 &&

No commits

} + {!loading && commits && commits.length > 0 && ( + + + {commits.map((c: any) => ( + + + + + + ))} + +
+ + {c.commit_sha.slice(0, 7)} + + + {c.commit_message?.split('\n')[0]?.slice(0, 60) || '—'} + + {c.repo?.split('/')[1] || c.repo} +
+ )} +
+
, + document.body, + ) : null; + + return ( + + + {count} + + {tooltip} + + ); +} + +function JiraCountWithTooltip({ + count, reportId, login, cacheRef, +}: { + count: number; reportId: string; login: string; + cacheRef: React.RefObject>; +}) { + 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 triggerRef = useRef(null); + const hideTimeout = useRef | null>(null); + + 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 }); + } + setShow(true); + const key = `jira:${reportId}:${login}`; + if (cacheRef.current!.has(key)) { setIssues(cacheRef.current!.get(key)!); return; } + setLoading(true); + try { + const rows = await fetch(`/api/report/${reportId}/jira-issues?login=${login}`).then(r => r.json()); + cacheRef.current!.set(key, rows); + setIssues(rows); + } catch { setIssues([]); } + setLoading(false); + } + + function handleMouseLeave() { + hideTimeout.current = setTimeout(() => setShow(false), 200); + } + + const tooltip = show && typeof document !== 'undefined' ? createPortal( +
{ if (hideTimeout.current) clearTimeout(hideTimeout.current); }} + onMouseLeave={handleMouseLeave} + > + {loading &&
Loading...
} + {issues && issues.length === 0 &&
No issues found
} + {issues && issues.map((issue: any) => ( + + {issue.issue_key} + {issue.summary?.slice(0, 50)} + + ))} +
, + document.body, + ) : null; + + return ( + + + {count} + + {tooltip} + + ); +} +``` + +- [ ] **Step 2: Run the test suite to confirm TypeScript compiles** + +```bash +npm test --no-coverage 2>&1 | tail -8 +``` + +Expected: All tests pass (no TypeScript errors in the new file — it hasn't been imported yet). + +- [ ] **Step 3: Commit** + +```bash +git add src/app/report/\[id\]/team/dev-table.tsx +git commit -m "feat(glook-12): add DevTable component with sortable columns" +``` + +--- + +### Task 2: Update `page.tsx` to use DevTable + +**Files:** +- Modify: `src/app/report/[id]/team/page.tsx` + +**Context:** After this task: +- `Developer` interface is imported from `./dev-table` (removed from page) +- `TYPE_COLORS` constant is removed from page +- `commitCache` and `jiraCache` refs are removed from `TeamSummaryPage` +- The developer table IIFE (lines 403–523) is replaced with `` +- Helper functions that moved to `dev-table.tsx` are removed: `ComplexityBadge`, `ImpactBadge`, `AiPercentBadge`, `PrPercentBadge`, `TypeBreakdown`, `CommitCountWithTooltip`, `JiraCountWithTooltip` +- `createPortal` import is removed (only used in moved tooltip components) +- `useRef` is removed from the React import (no longer used in page) + +`TeamPulseCard` and `renderPulseMarkdown` stay in `page.tsx`. + +- [ ] **Step 1: Update imports at the top of `page.tsx`** + +Change the import block from: + +```typescript +import { useState, useRef } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import useSWR from 'swr'; +import { createPortal } from 'react-dom'; +import ChatPanel from '@/app/chat-panel'; +import { useAuth } from '@/app/auth-context'; +import { useUrlState, useUrlBatch } from '@/lib/url-state'; +import TeamTable from './team-table'; +import ProjectsCard from '@/components/ProjectsCard'; +import IntegrityBadge from '@/components/IntegrityBadge'; +import type { TeamProject } from '@/lib/team-pulse/types'; +import type { RunMetadata } from '@/lib/report-runner/types'; + +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; + total_jira_issues?: number; + cc_total_cost?: number; + cc_requests?: number; + type_breakdown: Record; + active_repos: string[]; +} +``` + +to: + +```typescript +import { useState } from 'react'; +import { useParams } from 'next/navigation'; +import useSWR from 'swr'; +import ChatPanel from '@/app/chat-panel'; +import { useAuth } from '@/app/auth-context'; +import { useUrlState, useUrlBatch } from '@/lib/url-state'; +import TeamTable from './team-table'; +import DevTable from './dev-table'; +import type { Developer } from './dev-table'; +import ProjectsCard from '@/components/ProjectsCard'; +import IntegrityBadge from '@/components/IntegrityBadge'; +import type { TeamProject } from '@/lib/team-pulse/types'; +import type { RunMetadata } from '@/lib/report-runner/types'; +``` + +Also remove `useRouter` from the component body (it's only used in the IIFE being deleted): + +```typescript + // DELETE this line from TeamSummaryPage: + const router = useRouter(); +``` + +- [ ] **Step 2: Remove `TYPE_COLORS` and cache refs from `TeamSummaryPage`** + +Remove the `TYPE_COLORS` constant (lines 52–60 — the block that defines the `Record` mapping). + +Inside `TeamSummaryPage`, remove the two `useRef` lines: + +```typescript + const commitCache = useRef>(new Map()); + const jiraCache = useRef>(new Map()); +``` + +- [ ] **Step 3: Replace the developer table IIFE with ``** + +Find the developer table block: + +```tsx + {/* Developer table */} + {(() => { + const filteredDevs = filterLogins.size > 0 ? developers.filter(d => filterLogins.has(d.github_login)) : developers; + const hasJira = developers.some(d => (d.total_jira_issues ?? 0) > 0); + const hasSpend = canAct && developers.some(d => Number(d.cc_total_cost ?? 0) > 0); + // Absolute rank across the full server-sorted developer list — shown + // alongside the filter-relative rank so users can still see where a + // developer sits across the whole team even when filtered down. + const absoluteRanks = new Map(); + developers.forEach((d, idx) => absoluteRanks.set(d.github_login, idx + 1)); + const showAbsolute = filterLogins.size > 0; + return filteredDevs.length > 0 && ( +
+``` + +(this block runs through to the closing `})()}` around line 523) + +Replace the entire IIFE block with: + +```tsx + {/* Developer table */} + +``` + +- [ ] **Step 4: Remove moved helper functions from the bottom of `page.tsx`** + +Delete these function declarations (they now live in `dev-table.tsx`): +- `function ComplexityBadge(...)` and its body +- `function ImpactBadge(...)` and its body +- `function AiPercentBadge(...)` and its body +- `function PrPercentBadge(...)` and its body +- `function TypeBreakdown(...)` and its body +- `function CommitCountWithTooltip(...)` and its entire body (including the portal JSX) +- `function JiraCountWithTooltip(...)` and its entire body (including the portal JSX) + +Keep: `TeamPulseCard`, `renderPulseMarkdown`. + +Also remove the closing `}` of `JiraCountWithTooltip` and any trailing code after it (the last function in the file). The file should end after the last closing `}` of whichever function is last. + +- [ ] **Step 5: Run the test suite** + +```bash +npm test --no-coverage 2>&1 | tail -8 +``` + +Expected: All tests pass. + +- [ ] **Step 6: TypeScript check** + +```bash +npx tsc --noEmit 2>&1 | grep -v " 2\.ts" +``` + +Expected: No errors. + +- [ ] **Step 7: Commit** + +```bash +git add src/app/report/\[id\]/team/page.tsx +git commit -m "feat(glook-12): wire DevTable into team page, remove inlined table IIFE" +``` 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 `