Skip to content

feat(team-summary): team comparison view with three impact strategies (GLOOK-4)#42

Merged
msogin merged 15 commits into
mainfrom
spec/glook-4-team-comparison
May 20, 2026
Merged

feat(team-summary): team comparison view with three impact strategies (GLOOK-4)#42
msogin merged 15 commits into
mainfrom
spec/glook-4-team-comparison

Conversation

@msogin
Copy link
Copy Markdown
Contributor

@msogin msogin commented May 19, 2026

Summary

Implements GLOOK-4: a new Teams tab on /report/[id]/team that compares teams against each other using the same impact dimensions as the IC table. Adds three impact columns (Weighted / Avg / Total) and full column parity with the Individuals table.

Spec: docs/superpowers/specs/2026-05-19-glook-4-team-comparison-design.md
Plan: docs/superpowers/plans/2026-05-19-glook-4-team-comparison.md

Impact formulas

For each team, three numbers — all derived from the existing IC computeImpactScore formula, no new weights:

  • (W) Weighted — per-capita-then-apply. Default sort. Divide additive metrics (commits, PRs, jira, reviews) by the authoritative team size from team_members, then run the IC formula. Inactive members still dilute per-capita — by design.
  • (A) Avg. Arithmetic mean of active developers' impact scores.
  • (T) Total — sum-then-apply. IC formula applied to team-level sums. Useful context but saturates fast (the IC formula's min(x/N, 1) caps engage at single-developer scale).

Brainstormed against real data from your latest report — scripts/team-impact-poc.ts was used to compare strategies side-by-side on real team rollups. (W) was the most defensible primary sort: it differentiates between teams without inflating large teams or unfairly rewarding small teams.

Architecture

  • Pure client-side aggregation. The page already fetches /api/report/[id] (devs) and /api/teams?org=<org> (team members). A new pure function aggregateTeams(developers, teams) → TeamRow[] runs inside a useMemo and feeds a new <TeamTable /> component. No new API endpoint, no DB migration.
  • computeImpactScore extracted to src/lib/impact-score.ts so server (per-dev impact at report run time) and client (team-level impact) call the same function. No drift.

UI

  • Tab strip on the existing page, styled identically to /report/[id]/org. URL state ?view=individuals | teams, default individuals — existing bookmarks unaffected.
  • Column parity with the IC table: Team, Size, Active (with −N indicator when inactive), PRs, Commits, Lines +/−, Cmplx, PR%, AI%, Jira (conditional), Spend (admin-only, conditional), Impact W/A/T.
  • Every column header is a sort button with ▲/▼ caret. Default sort is Impact (W) desc. Sort state is URL-stated (?sort=…&dir=…) so it survives page reload and shareable links.
  • Row click drills back to the Individuals view filtered to that team — sets both team (drives TeamPulseCard) and dev (drives the dev-filter chips and table) URL state, mirroring the existing dropdown behavior.

Files

  • New src/lib/impact-score.ts — extracted computeImpactScore (function move, no logic change).
  • New src/lib/teams/team-aggregator.tsaggregateTeams(developers, teams) pure function.
  • New src/app/report/[id]/team/team-table.tsx<TeamTable /> component.
  • New src/lib/__tests__/unit/impact-score.test.ts — pinned numeric assertions on the extracted formula.
  • New src/lib/__tests__/unit/team-aggregator.test.ts — 15 tests covering sum aggregation, weighted ratios, type/repo aggregations, the three impact strategies, single-member team invariant (W = T), multi-team membership, no-team devs, orphan team skip.
  • New scripts/team-impact-poc.ts — one-off used during brainstorming to compare formulas against real data; useful for future formula tweaks.
  • New spec at docs/superpowers/specs/2026-05-19-glook-4-team-comparison-design.md.
  • New plan at docs/superpowers/plans/2026-05-19-glook-4-team-comparison.md.
  • Modified src/lib/aggregator.ts — re-export computeImpactScore from the new module so existing callers stay unchanged.
  • Modified src/app/report/[id]/team/page.tsx?view= URL state, tab strip, conditional render, hide filter dropdown + dev-search + TeamPulseCard on Teams view.

Tests

  • npm test → 62 suites / 647 tests pass.
  • npx tsc --noEmit -p tsconfig.json → clean.
  • The two pure modules are fully unit-tested (16 tests on impact-score + 15 on team-aggregator). UI smoke happened locally per the plan's Task 10 checklist.

Known caveats (documented in spec)

  • Single-member team: W = T holds exactly (per-capita-with-size-1 collapses to total). A may differ by ≤0.5 because the persisted impact_score was computed with totalStoryPoints which isn't exposed via /api/report. Tolerable for v1; smallest follow-up fix is adding total_story_points to the report SELECT.
  • Devs in multiple teams contribute to each team's totals (no de-duping in v1). Documented via tooltip on the Team column.
  • Period-over-period trend arrow on team row is deferred to v2.

Test plan

  • Open /report/{id}/team — defaults to Individuals tab, no behavior change.
  • Click Teams tab — table swaps to one row per team, sorted by Impact (W) desc.
  • Click column headers — sort flips direction (caret toggles).
  • Reload the page on ?view=teams&sort=total_commits&dir=desc — sort preserved.
  • Click a team row — URL updates to ?view=individuals&team=<name>&dev=<login>&dev=<login>…; IC view filters to those members; TeamPulseCard appears.
  • Teams with inactive members show N −M in Active column.
  • Spend column hidden for non-admins; Jira column hidden if no team has Jira issues.

🤖 Generated with Claude Code

msogin and others added 14 commits May 19, 2026 16:32
Captures the design for the new Teams tab on /report/[id]/team that
compares teams instead of individual contributors. Key decisions:

- Primary impact metric (W) = per-capita-then-apply with authoritative
  team size from team_members (not "active devs that shipped this
  period"). Same IC impact formula reused via an extracted shared module.
- Two secondary impact columns retained: (A) arithmetic mean of active
  devs' impacts, (T) sum-then-apply.
- All columns sortable, default sort by W desc.
- Tab strip on the existing page, URL state ?view=teams. Default remains
  ?view=individuals so existing bookmarks are unaffected.
- Row click drills back to Individuals filtered by that team via the
  existing team-filter URL state.
- Aggregation runs client-side via a pure aggregateTeams() function;
  no new API endpoint.

Also adds scripts/team-impact-poc.ts — the one-off used during
brainstorming to compare W, A, T against real data from the latest
report.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
…mplementation

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
10 bite-sized tasks: extract impact-score module (Task 1), build
aggregateTeams via TDD (Tasks 2-7), TeamTable component (Task 8),
wire Teams tab + URL state into team/page.tsx (Task 9), local smoke
(Task 10).

Co-Authored-By: Claude <noreply@anthropic.com>
…e (GLOOK-4)

Same function, new home. Server-side aggregator continues to import it
through src/lib/aggregator.ts (re-export), so no callers change. Adds a
focused test file that pins the formula's numeric behavior, which the
upcoming team aggregator will rely on.

Co-Authored-By: Claude <noreply@anthropic.com>
Empty implementation, ready for incremental TDD in the next tasks.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
…a (GLOOK-4)

Co-Authored-By: Claude <noreply@anthropic.com>
…K-4)

Co-Authored-By: Claude <noreply@anthropic.com>
…ill-down (GLOOK-4)

Co-Authored-By: Claude <noreply@anthropic.com>
URL-stated ?view=individuals|teams, defaults to individuals. Existing
filter dropdown, dev-search, and TeamPulseCard render only on the
individuals tab.

Co-Authored-By: Claude <noreply@anthropic.com>
Smoke-test feedback on Task 10:

- Sort state is now stored via useUrlState (?sort=…&dir=…) so a hard
  reload keeps the active sort column and direction.
- Row click now also sets the dev URL state (?dev=login1&dev=login2…)
  using the authoritative team_members list, mirroring what the in-page
  team-filter dropdown does. The IC tab now drills in with both the
  team-pulse selection AND the dev-filter chips populated, matching
  existing muscle memory.

Co-Authored-By: Claude <noreply@anthropic.com>
…ecimals (GLOOK-4)

cc_total_cost is stored in cents (decimal(10,2)); the IC table already
renders it as $(value / 100).toFixed(2). Team totals were 100× too high
and overly precise. Switch to Math.round(value / 100).toLocaleString()
to match: whole dollars, thousands separators, matching green-mono
styling.

Co-Authored-By: Claude <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor Author

@msogin msogin left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review findings — 4 suggested fixes, 1 question

Reviewed production code only. Tests pass, TypeScript clean, architecture is sound. Four issues worth addressing before merge:

I1 — teams as AggregatorTeam[] cast bypasses TypeScript

File: src/app/report/[id]/team/page.tsx

The local Team interface ({ id, name, members }) is missing color: string, which AggregatorTeam requires. The cast papers over the gap. Fix: add color: string to the local Team interface, remove the cast.

I3 — Teams SWR waterfall renders false "No teams configured" during load

File: src/app/report/[id]/team/page.tsx

The teams SWR key depends on org from the first SWR. During the window between activeReport resolving and teamsData resolving, teams=[] so aggregateTeams returns [] and the empty-state message fires incorrectly.

Fix: change the TeamTable render condition from:
{view === "teams" && activeReport && ...}
to:
{view === "teams" && activeReport && teamsData !== undefined && ...}

No loading placeholder needed — this is a fast sequential same-origin fetch.

S1 — cc_total_cost in SORT_KEYS but column hidden for non-admins

File: src/app/report/[id]/team/team-table.tsx

A non-admin opening a shared link with ?sort=cc_total_cost sees the table sorted by an invisible column with no sort caret.

Fix: const effectiveSortKey = (!hasSpend && sortKey === "cc_total_cost") ? "impact_weighted" : sortKey; — use effectiveSortKey in sortedRows useMemo and sortCaret.

S2 — Lines +/- column missing toLocaleString

File: src/app/report/[id]/team/team-table.tsx

IC table uses toLocaleString() for line counts; team table does not. On high-volume orgs team totals exceed 50k.

Fix: row.lines_added.toLocaleString() and row.lines_removed.toLocaleString()

Question (S8)

team-aggregator.ts: If team.members contains a duplicate login, devByLogin.get deduplicates silently but team.members.length (used as teamSize) is inflated, deflating impact_weighted without any signal. Is member-list uniqueness guaranteed by the server?

Phase 1 flags (non-blocking)

  • scripts/team-impact-poc.ts (163 lines): PR description calls it "one-off used during brainstorming." Consider whether it belongs merged to main.
  • docs/superpowers/plans + docs/superpowers/specs (1290 lines combined): planning artifacts that go stale post-merge. The PR description already captures the rationale — consider Confluence/Jira instead of source control.

Copy link
Copy Markdown
Contributor Author

@msogin msogin left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review findings for PR 42 - see summary below

@msogin
Copy link
Copy Markdown
Contributor Author

msogin commented May 20, 2026

Review findings — 4 fixes, 1 question

Reviewed production code only (, , , , ). Tests pass, TypeScript clean, architecture sound. Four issues before merge:


I1 — teams as AggregatorTeam[] cast bypasses TypeScript

File: src/app/report/[id]/team/page.tsx

Local Team interface is missing color: string, which AggregatorTeam requires. The cast papers over the gap — if color is absent, style={{ background: row.color }} silently renders nothing.

Fix: Add color: string to the local Team interface, remove the as AggregatorTeam[] cast.


I3 — Teams SWR waterfall renders false "No teams configured" during load

File: src/app/report/[id]/team/page.tsx

The teams SWR key depends on org from the first SWR. During the window between activeReport resolving and teamsData resolving, teams=[]aggregateTeams returns [] → the empty-state message fires incorrectly.

Fix: Gate TeamTable on teamsData having resolved:

// was:
{view === "teams" && activeReport && ( <TeamTable ... /> )}
// becomes:
{view === "teams" && activeReport && teamsData !== undefined && ( <TeamTable ... /> )}

No loading placeholder needed — this is a fast sequential same-origin fetch.


S1 — cc_total_cost in SORT_KEYS but column hidden for non-admins

File: src/app/report/[id]/team/team-table.tsx

A non-admin opening a shared link with ?sort=cc_total_cost sees a table sorted by an invisible column with no caret.

Fix:

const effectiveSortKey = (!hasSpend && sortKey === "cc_total_cost") ? "impact_weighted" : sortKey;
// use effectiveSortKey in sortedRows useMemo and sortCaret

S2 — Lines +/- column missing .toLocaleString()

File: src/app/report/[id]/team/team-table.tsx

IC table uses toLocaleString() for line counts; team table does not. Team totals on high-volume orgs easily exceed 50k.

Fix: +{row.lines_added.toLocaleString()} / −{row.lines_removed.toLocaleString()}


Question (S8)

team-aggregator.ts: If team.members contains a duplicate login, devByLogin.get deduplicates silently but team.members.length (used as teamSize) is inflated — deflating impact_weighted with no signal. Is member-list uniqueness guaranteed by the server?


Phase 1 flags (non-blocking, team judgment call)

  • scripts/team-impact-poc.ts (163 lines): PR says it's a "one-off used during brainstorming" — does it belong in main?
  • docs/superpowers/plans + docs/superpowers/specs (1,290 lines): planning artifacts that go stale post-merge. PR description already captures the rationale — consider Confluence/Jira instead.

I1 — local Team interface was missing `color: string`, papered over by a
`teams as AggregatorTeam[]` cast. The /api/teams response already
includes color, so the local type just needed widening. Cast and now-
unused import removed.

I3 — TeamTable rendered before the /api/teams SWR resolved, so a brief
window during initial load with `teams=[]` triggered the false
"No teams configured" empty state. Gate on `teamsData !== undefined`.

S1 — sort URL state could point at a column hidden for non-admins
(Spend) or for orgs without Jira issues. Render then showed a sort with
no caret indicator. Add an `effectiveSortKey` fallback to
`impact_weighted` when the URL-specified column is invisible; this
fallback is used by both the sort comparator and the caret.

S2 — Lines +/- column showed bare integers; team totals can run into
the tens of thousands. Switch to `.toLocaleString()` to match the IC
table's formatting.

S8 — Not a code change. Reviewer asked whether `team.members` could
contain duplicates (which would inflate teamSize and deflate
impact_weighted). DB has UNIQUE KEY (team_id, github_login) on
team_members, so duplicates are structurally impossible.

Co-Authored-By: Claude <noreply@anthropic.com>
@msogin msogin merged commit 674c42a into main May 20, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant