Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 181 additions & 0 deletions docs/superpowers/plans/2026-06-16-glook-23-projects-sort-scale.md
Original file line number Diff line number Diff line change
@@ -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 (
<div className={`flex items-center gap-2 text-gray-500 text-sm ${variant === 'collapsible' ? 'py-6 justify-center' : 'mt-3'}`}>
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Analyzing projects…
</div>
);
}
if (projects.length === 0) {
return <p className={`text-sm text-gray-500 ${variant === 'collapsible' ? 'py-4' : 'mt-2'}`}>{emptyMessage}</p>;
}

// 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 (
<div className={`space-y-3 ${variant === 'collapsible' ? 'mt-3' : 'mt-4'}`}>
{/* Legend */}
<div className="flex gap-3 text-[10px] text-gray-600 pl-6">
<span className="flex items-center gap-1">
<span className="inline-block w-2 h-2 rounded-[2px]" style={{ background: '#06B6D4' }} />PRs
</span>
<span className="flex items-center gap-1">
<span className="inline-block w-2 h-2 rounded-[2px]" style={{ background: '#A855F7' }} />Jiras
</span>
<span className="flex items-center gap-1">
<span className="inline-block w-2 h-2 rounded-[2px]" style={{ background: 'rgba(255,255,255,0.18)' }} />Commits
</span>
</div>

{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 (
<div key={i} className="bg-white/[0.02] rounded-lg p-3">
<div className="flex items-start justify-between gap-3 mb-1">
<div className="flex items-center gap-2 min-w-0">
<span className="text-xs text-gray-600 w-4 shrink-0 text-right">{i + 1}</span>
<span className="text-sm font-semibold text-white">{p.name}</span>
</div>
<div className="flex items-center gap-3 shrink-0 text-[11px] text-gray-500">
<span>{p.jira_count} jiras</span>
<span>~{p.estimated_commits} commits</span>
<span>~{p.estimated_prs} PRs</span>
{ago && <span className="text-gray-600">· {ago}</span>}
</div>
</div>

{/* Volume bar: width = total / max, segments = PRs (cyan) + Jiras (purple) + Commits (ghost) */}
<div className="pl-6 mb-1.5">
<div className="h-[5px] rounded-sm overflow-hidden" style={{ background: 'rgba(255,255,255,0.05)' }}>
<div className="h-full flex" style={{ width: `${barPct}%` }}>
<div style={{ flex: p.estimated_prs, background: '#06B6D4' }} />
<div style={{ flex: p.jira_count, background: '#A855F7' }} />
<div style={{ flex: p.estimated_commits, background: 'rgba(255,255,255,0.10)' }} />
</div>
</div>
</div>

<p className="text-xs text-gray-500 pl-6 mb-1.5">{p.summary}</p>
<div className="flex gap-1 pl-6 flex-wrap">
{p.developers.map(d =>
developerHref ? (
<a
key={d}
href={developerHref(d)}
className="text-[10px] px-1.5 py-0.5 rounded hover:opacity-80 transition-opacity"
style={{ color: 'var(--accent-dark)', backgroundColor: 'color-mix(in srgb, var(--accent) 8%, transparent)' }}
>@{d}</a>
) : (
<span
key={d}
className="text-[10px] px-1.5 py-0.5 rounded"
style={{ color: 'var(--accent-dark)', backgroundColor: 'color-mix(in srgb, var(--accent) 8%, transparent)' }}
>@{d}</span>
),
)}
</div>
</div>
);
})}
</div>
);
}
```

- [ ] **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"
```
Original file line number Diff line number Diff line change
@@ -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 |
16 changes: 16 additions & 0 deletions src/app/api/project-insights/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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,
});
}
Expand Down Expand Up @@ -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) {
Expand Down
3 changes: 3 additions & 0 deletions src/app/llm-findings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export default function LlmFindings() {
const [projects, setProjects] = useState<ProjectInsight[]>([]);
const [untrackedWork, setUntrackedWork] = useState<UntrackedWork[]>([]);
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
Expand Down Expand Up @@ -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(() => {})
Expand Down Expand Up @@ -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}
/>
)}

Expand Down
Loading