diff --git a/docs/superpowers/plans/2026-06-16-glook-23-projects-sort-scale.md b/docs/superpowers/plans/2026-06-16-glook-23-projects-sort-scale.md new file mode 100644 index 0000000..edcca35 --- /dev/null +++ b/docs/superpowers/plans/2026-06-16-glook-23-projects-sort-scale.md @@ -0,0 +1,181 @@ +# GLOOK-23: Projects Card Sort + Volume Bar + +> **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:** Sort project cards by PRs+Jiras and add a segmented volume bar so users can instantly read project size and composition. + +**Architecture:** Single-file change to `src/components/ProjectsCard.tsx`. Inside `ProjectsBody`, sort the incoming array by `(estimated_prs + jira_count)` DESC, compute `maxVolume` across all projects, add a one-line legend before the list, and insert a `6px` segmented bar between the stats row and the summary text. Both the home-page (standalone) and team-page (collapsible) surfaces use the same `ProjectsBody` component and get the change automatically. + +**Tech Stack:** React, TypeScript, Tailwind CSS. No new dependencies. + +--- + +## File Map + +| File | Change | +|---|---| +| `src/components/ProjectsCard.tsx` | Sort by PRs+Jiras; add `maxVolume` computation; add legend; add volume bar per row | + +--- + +### Task 1: Add sort, legend, and volume bar to `ProjectsBody` + +**Files:** +- Modify: `src/components/ProjectsCard.tsx:45-113` + +**Context:** `ProjectsBody` receives `projects: ProjectsCardItem[] | TeamProject[]`. Both interfaces already have `jira_count`, `estimated_commits`, and `estimated_prs`. The function currently renders projects in the order they arrive (LLM output order). After this change: + +1. Projects are sorted by `estimated_prs + jira_count` DESC before rendering. +2. `maxVolume = max(estimated_prs + jira_count + estimated_commits)` computed once across all sorted projects. +3. A small legend (`PRs · Jiras · Commits`) appears before the list. +4. Each project row gets a `6px` tall bar inserted between the stats line and the summary text. Bar width = `(estimated_prs + jira_count + estimated_commits) / maxVolume * 100%`. Inside the bar three flex segments: PRs (cyan), Jiras (purple), Commits (ghost). + +No tests exist for this component — verify visually against the running app. + +- [ ] **Step 1: Replace the `ProjectsBody` function with the updated version** + +Find and replace the entire `ProjectsBody` function (lines 45–114 in the current file). The new version: + +```tsx +function ProjectsBody({ + projects, + loading, + emptyMessage, + developerHref, + variant, +}: { + projects: ProjectsCardItem[] | TeamProject[]; + loading?: boolean; + emptyMessage: string; + developerHref?: (login: string) => string; + variant: 'standalone' | 'collapsible'; +}) { + if (loading) { + return ( +
+ + + + + Analyzing projects… +
+ ); + } + if (projects.length === 0) { + return

{emptyMessage}

; + } + + // Sort by meaningful output (PRs + Jiras). Commits are squashed into PRs so + // using raw commit count would inflate commit-heavy, low-PR projects. + const sorted = [...projects].sort( + (a, b) => (b.estimated_prs + b.jira_count) - (a.estimated_prs + a.jira_count), + ); + + // Max total volume across all projects — used to normalise bar widths. + const maxVolume = Math.max( + ...sorted.map(p => p.estimated_prs + p.jira_count + p.estimated_commits), + 1, + ); + + return ( +
+ {/* Legend */} +
+ + PRs + + + Jiras + + + Commits + +
+ + {sorted.map((p, i) => { + const ago = timeAgo((p as TeamProject).last_activity); + const totalVol = p.estimated_prs + p.jira_count + p.estimated_commits; + const barPct = (totalVol / maxVolume) * 100; + return ( +
+
+
+ {i + 1} + {p.name} +
+
+ {p.jira_count} jiras + ~{p.estimated_commits} commits + ~{p.estimated_prs} PRs + {ago && · {ago}} +
+
+ + {/* Volume bar: width = total / max, segments = PRs (cyan) + Jiras (purple) + Commits (ghost) */} +
+
+
+
+
+
+
+
+
+ +

{p.summary}

+
+ {p.developers.map(d => + developerHref ? ( + @{d} + ) : ( + @{d} + ), + )} +
+
+ ); + })} +
+ ); +} +``` + +- [ ] **Step 2: Run the full test suite** + +```bash +npm test --no-coverage +``` + +Expected: All tests pass (no changes to logic or data — pure UI addition). + +- [ ] **Step 3: Start the dev server and verify visually** + +```bash +npm run dev +``` + +Open the home page and check: +- Projects are sorted by PRs+Jiras (largest first, not LLM output order) +- A small legend appears: `■ PRs ■ Jiras ░ Commits` +- Each project has a `5px` bar between the stats and summary +- Bar widths differ — the largest project fills 100%, others are proportionally shorter +- Segment colours: cyan (PRs), purple (Jiras), near-invisible ghost (Commits) +- Commit-heavy projects (low PRs/Jiras) rank lower and have large ghost tails + +Also open the team page and expand a Current Projects card — same visual should appear. + +- [ ] **Step 4: Commit** + +```bash +git add src/components/ProjectsCard.tsx +git commit -m "feat(glook-23): sort projects by PRs+Jiras, add segmented volume bar" +``` diff --git a/docs/superpowers/specs/2026-06-16-glook-23-projects-sort-scale-design.md b/docs/superpowers/specs/2026-06-16-glook-23-projects-sort-scale-design.md new file mode 100644 index 0000000..f8ef32e --- /dev/null +++ b/docs/superpowers/specs/2026-06-16-glook-23-projects-sort-scale-design.md @@ -0,0 +1,90 @@ +# GLOOK-23: Projects Card — Sort by Volume + Visual Scale Bar + +## Goal + +Sort project cards by meaningful output (PRs + Jiras) and add a visual volume bar so users can instantly read project size and composition without scanning numbers. + +## Problem + +The home-page and team-page "Current Projects" cards currently render projects in LLM-output order with only text counters (`5 jiras · ~30 commits · ~12 PRs`). There is no visual hierarchy, and commit counts visually dominate even though commits are squashed into PRs and are a lower-signal metric. + +## Design (validated with live Smartling data in visual companion) + +### Sorting + +Sort projects by **PRs + Jiras** descending (meaningful shipped output). Commits are excluded from the sort key because individual commits collapse into PRs — using commit count would inflate commit-heavy, low-PR projects like the Salesforce CC project, which had 116 commits but only 8 PRs and 9 Jiras. + +### Volume Bar + +Each project row gets a horizontal bar immediately below the stats line: + +``` +[cyan: PRs][purple: Jiras][ghost: Commits] +|←————— bar width = total / max_total ——————→| ←— gray track (100%) +``` + +- **Track** (background): full width, `rgba(255,255,255,0.05)`, `6px` tall, rounded +- **Fill** (foreground): `width = (estimated_prs + jira_count + estimated_commits) / maxVolume * 100%` + - Max is computed client-side from the full `projects` array before rendering +- **Segments** inside fill (using CSS `flex` with raw counts as flex values): + - PRs → `#06B6D4` (cyan) + - Jiras → `#A855F7` (purple) + - Commits → `rgba(255,255,255,0.10)` (ghost — visible context, not dominant) +- Segment order: PRs → Jiras → Commits (bright metrics first, ghost last) + +### Effect on real data (Salesforce CC example) + +With the old order, Salesforce CC ranked #2 (116 commits). With PRs+Jiras sort it ranks #8 (8 PRs, 9 Jiras). Its bar is 50% wide (total volume still significant) but the ghost section visually dominates — immediately communicating "this project is commit-heavy with little shipped output." No text change required; the visual explains itself. + +--- + +## Architecture + +**One file changed: `src/components/ProjectsCard.tsx`** + +The `ProjectsBody` subcomponent receives `projects: ProjectsCardItem[] | TeamProject[]`. Both interfaces already have `jira_count`, `estimated_commits`, `estimated_prs`. + +Changes inside `ProjectsBody`: +1. Sort the incoming `projects` array by `(p.estimated_prs + p.jira_count)` DESC before rendering. +2. Compute `maxVolume = Math.max(...projects.map(p => p.estimated_prs + p.jira_count + p.estimated_commits), 1)`. +3. For each project row, add the bar after the stats line and before the summary. + +```tsx +// Sort by PRs + Jiras +const sorted = [...projects].sort((a, b) => + (b.estimated_prs + b.jira_count) - (a.estimated_prs + a.jira_count) +); +const maxVolume = Math.max(...sorted.map(p => + p.estimated_prs + p.jira_count + p.estimated_commits +), 1); + +// Inside the map: +const totalVol = p.estimated_prs + p.jira_count + p.estimated_commits; +const barWidth = (totalVol / maxVolume) * 100; +// Render bar with flex segments: flex={p.estimated_prs}, flex={p.jira_count}, flex={p.estimated_commits} +``` + +This applies to **both surfaces** (home page and team page) because both use `ProjectsCard` / `ProjectsBody`. + +--- + +## Legend + +No separate legend component needed. The `ProjectsCard` header area (or a small inline legend below the title) shows: +- `■ PRs ■ Jiras ░ Commits` + +Position: a single line of 10px text added to the `ProjectsBody` before the list, only when `projects.length > 0`. + +--- + +## Tests + +No unit tests needed — `ProjectsCard` is a pure UI component with no existing test coverage. The sort logic is a one-liner `useMemo` or inline sort; correctness is verified by visual inspection against the live app. + +--- + +## Files Changed + +| File | Change | +|---|---| +| `src/components/ProjectsCard.tsx` | Sort by PRs+Jiras; add volume bar with ghost commits; add legend | diff --git a/src/app/api/project-insights/route.ts b/src/app/api/project-insights/route.ts index 46e35e2..062da50 100644 --- a/src/app/api/project-insights/route.ts +++ b/src/app/api/project-insights/route.ts @@ -26,6 +26,20 @@ async function getHandler() { return NextResponse.json({ available: false }); } + // Actual org totals — used by the client to render an "Other" row showing + // activity not captured in the top-10 project clusters. Fetched before the + // cache check so both the cache-hit and fresh-generation paths can return them. + const [totalsRows] = await db.execute( + `SELECT COALESCE(SUM(total_commits), 0) AS commits, COALESCE(SUM(total_prs), 0) AS prs + FROM developer_stats WHERE report_id = ?`, + [report.id], + ) as [any[], any]; + const totals = { + commits: Number(totalsRows[0]?.commits ?? 0), + prs: Number(totalsRows[0]?.prs ?? 0), + jiras: Number(jiraCount[0]?.cnt ?? 0), + }; + // Check cache (reuse report_comparisons table — use report.id for both a and b to distinguish from real comparisons) // Real comparisons always have different a and b. Project insights use same id for both. const [cached] = await db.execute( @@ -43,6 +57,7 @@ async function getHandler() { available: true, report: { id: report.id, org: report.org, periodDays: report.period_days, createdAt: report.created_at }, ...rest, + totals, cached: true, }); } @@ -218,6 +233,7 @@ ${noJiraData}${inflightBlock}`; report: { id: report.id, org: report.org, periodDays: report.period_days, createdAt: report.created_at }, projects: toCache.projects, untracked_work: toCache.untracked_work, + totals, cached: false, }); } catch (err) { diff --git a/src/app/llm-findings.tsx b/src/app/llm-findings.tsx index bb598dc..e285bb4 100644 --- a/src/app/llm-findings.tsx +++ b/src/app/llm-findings.tsx @@ -35,6 +35,7 @@ export default function LlmFindings() { const [projects, setProjects] = useState([]); const [untrackedWork, setUntrackedWork] = useState([]); const [projectsMeta, setProjectsMeta] = useState<{ id: string; org: string; periodDays: number; createdAt: string } | null>(null); + const [projectTotals, setProjectTotals] = useState<{ commits: number; prs: number; jiras: number } | null>(null); const [projectsLoading, setProjectsLoading] = useState(true); // Release notes @@ -85,6 +86,7 @@ export default function LlmFindings() { setProjects(data.projects || []); setUntrackedWork(data.untracked_work || []); setProjectsMeta({ id: data.report.id, org: data.report.org, periodDays: data.report.periodDays, createdAt: data.report.createdAt }); + if (data.totals) setProjectTotals(data.totals); } }) .catch(() => {}) @@ -143,6 +145,7 @@ export default function LlmFindings() { title="Top Projects" subtitle={projectsMeta ? `${projectsMeta.org} · ${projectsMeta.periodDays}d · ${formatDate(projectsMeta.createdAt)}` : undefined} developerHref={projectsMeta ? (login) => `/report/${projectsMeta.id}/dev/${login}` : undefined} + actualTotals={projectTotals ?? undefined} /> )} diff --git a/src/components/ProjectsCard.tsx b/src/components/ProjectsCard.tsx index 7a34da7..7ed2b45 100644 --- a/src/components/ProjectsCard.tsx +++ b/src/components/ProjectsCard.tsx @@ -1,6 +1,14 @@ 'use client'; +import { useMemo } from 'react'; import type { TeamProject } from '@/lib/team-pulse/types'; +// Segment colors used in both the legend swatches and the bar segments. +const SEGMENT_COLORS = { + prs: '#06B6D4', + jiras: '#A855F7', + commits: 'rgba(255,255,255,0.10)', +} as const; + /** Older shape used by the home-page LLM project insights — superset of TeamProject. * Allows the same component to serve both surfaces without a refactor of the home payload. */ export interface ProjectsCardItem { @@ -22,6 +30,10 @@ export interface ProjectsCardProps { /** Optional: link template for developer chips. Receives login, returns href. * If omitted, chips are not links. */ developerHref?: (login: string) => string; + /** When provided, an "Other (not in top N)" row is appended showing the + * activity not captured by the project clusters. Shown as a peer row with + * the same bar format so users can compare scale directly. */ + actualTotals?: { commits: number; prs: number; jiras: number }; /** Collapsible mode (GLOOK-11): header becomes a toggle button styled to * match , body hidden when collapsed. Controlled — parent * owns `expanded` state via `onExpandedChange`. */ @@ -48,12 +60,14 @@ function ProjectsBody({ emptyMessage, developerHref, variant, + actualTotals, }: { projects: ProjectsCardItem[] | TeamProject[]; loading?: boolean; emptyMessage: string; developerHref?: (login: string) => string; variant: 'standalone' | 'collapsible'; + actualTotals?: { commits: number; prs: number; jiras: number }; }) { if (loading) { return ( @@ -69,12 +83,67 @@ function ProjectsBody({ if (projects.length === 0) { return

{emptyMessage}

; } + + // Sort by meaningful output (PRs + Jiras). Commits excluded from sort key + // because they are squashed into PRs — including them would inflate rank for + // commit-heavy, low-PR projects. Commits are still shown in the bar for context. + const sorted = useMemo( + () => [...projects].sort((a, b) => (b.estimated_prs + b.jira_count) - (a.estimated_prs + a.jira_count)), + [projects], + ); + + // "Other" row: activity not attributed to any named project cluster. + // Only shown when actualTotals is provided (home page passes org totals from devStats). + const other = useMemo(() => { + if (!actualTotals) return null; + const sumCommits = sorted.reduce((s, p) => s + p.estimated_commits, 0); + const sumPrs = sorted.reduce((s, p) => s + p.estimated_prs, 0); + const sumJiras = sorted.reduce((s, p) => s + p.jira_count, 0); + const o = { + commits: Math.max(0, actualTotals.commits - sumCommits), + prs: Math.max(0, actualTotals.prs - sumPrs), + jiras: Math.max(0, actualTotals.jiras - sumJiras), + }; + return (o.commits + o.prs + o.jiras) > 0 ? o : null; + }, [sorted, actualTotals]); + + // Bar width = total volume (PRs + Jiras + Commits) / max across all projects. + // Commits are intentionally included here even though they are excluded from the + // sort key — the bar shows the full activity footprint of each project (sort rank + // reflects quality output, bar width reflects overall size). + // If "Other" is present, include it in maxVolume so bars are comparable. + const maxVolume = useMemo(() => { + const projectMax = sorted.reduce((max, p) => Math.max(max, p.estimated_prs + p.jira_count + p.estimated_commits), 1); + const otherTotal = other ? other.commits + other.prs + other.jiras : 0; + return Math.max(projectMax, otherTotal, 1); + }, [sorted, other]); + + // Hide Jira legend entry when Jira is disabled (all counts are 0). + const hasJira = useMemo(() => sorted.some(p => p.jira_count > 0) || (other?.jiras ?? 0) > 0, [sorted, other]); + return (
- {projects.map((p, i) => { + {/* Legend */} +
+ + + {hasJira && ( + + + )} + + +
+ + {sorted.map((p, i) => { const ago = timeAgo((p as TeamProject).last_activity); + const totalVol = p.estimated_prs + p.jira_count + p.estimated_commits; + const barPct = (totalVol / maxVolume) * 100; return ( -
+
{i + 1} @@ -87,6 +156,25 @@ function ProjectsBody({ {ago && · {ago}}
+ + {/* Volume bar: width = total / max, segments = PRs + Jiras + Commits (ghost) */} + {totalVol > 0 && ( +
+
+
+
+
+
+
+
+
+ )} +

{p.summary}

{p.developers.map(d => @@ -109,6 +197,47 @@ function ProjectsBody({
); })} + + {/* "Other" row — same visual style as a project, italic label, no summary/devs */} + {other && (() => { + const otherTotal = other.commits + other.prs + other.jiras; + const otherBarPct = (otherTotal / maxVolume) * 100; + return ( +
+
+
+ ~ + Other (not in top {sorted.length}) +
+
+ ~{other.jiras} jiras + ~{other.commits} commits + ~{other.prs} PRs +
+
+ {otherTotal > 0 && ( +
+
+
+
+
+
+
+
+
+ )} +
Approximate remainder — actual totals minus LLM cluster estimates
+
+ ); + })()}
); } @@ -120,6 +249,7 @@ export default function ProjectsCard({ subtitle, emptyMessage = 'No active projects in this window.', developerHref, + actualTotals, collapsible = false, expanded = true, onExpandedChange, @@ -162,6 +292,7 @@ export default function ProjectsCard({ emptyMessage={emptyMessage} developerHref={developerHref} variant="collapsible" + actualTotals={actualTotals} />
)} @@ -186,6 +317,7 @@ export default function ProjectsCard({ emptyMessage={emptyMessage} developerHref={developerHref} variant="standalone" + actualTotals={actualTotals} />
);