From 4d5371ec34fd16007b08b3ec81830797c7f746f0 Mon Sep 17 00:00:00 2001 From: Evgeniy Nepeipivo Date: Fri, 25 Sep 2026 10:00:13 -0400 Subject: [PATCH 01/53] GLOOK-58: design spec for Recharts v3 chart migration Co-Authored-By: Claude Opus 5.5 --- ...9-25-glook-58-recharts-migration-design.md | 224 ++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-25-glook-58-recharts-migration-design.md diff --git a/docs/superpowers/specs/2026-09-25-glook-58-recharts-migration-design.md b/docs/superpowers/specs/2026-09-25-glook-58-recharts-migration-design.md new file mode 100644 index 00000000..c3ce07ae --- /dev/null +++ b/docs/superpowers/specs/2026-09-25-glook-58-recharts-migration-design.md @@ -0,0 +1,224 @@ +# GLOOK-58: Replace hand-rolled SVG charts with Recharts v3 + +**Status:** design agreed 2026-09-25, pending implementation +**Ticket:** GLOOK-58 +**Approach:** adopt Recharts v3 with a ported copy of the shadcn/ui `chart.tsx` wrapper. The minimal alternative (keep the hand-rolled charts, swap hex for CSS variables, dedupe `TimelineChart`) was rejected; see [Approaches considered](#approaches-considered). + +## Problem + +Glooker has no charting library. Every chart is hand-built inline SVG or a Tailwind `
` sized by `style={{ width: '%' }}`. That produces visible bugs: + +- **Light mode is broken inside charts.** Light themes work by overriding Tailwind class colors under `[data-theme-mode="light"]` in `src/app/globals.css`. Chart grids, tooltips and labels use hex presentation attributes (`stroke="#1F2937"`, `report/[id]/org/page.tsx` lines 482, 529, 600, 868) and `fill-gray-*` classes, and no override exists for either. A card turns light while its chart internals stay dark. `ProgressRing` (`src/app/projects/progress-ring.tsx` lines 59-66) has no light path at all. +- **Values only appear on hover.** The donut (`PieChart`, `org/page.tsx:684`) has no resting legend values, and `TimelineChart` values live only in tooltips. A screenshot, PDF, phone or shared report shows charts with no numbers. +- **Axes are sparse.** The dev `TimelineChart` has three fixed x labels (`dev/[login]/page.tsx:830-838`) and y ticks that collapse to 2-3 irregular values (`:702-711`). `TrendChart` labels only its first and last dates (`trend-chart.tsx:32-33`). +- **`TimelineChart` exists twice and the copies have diverged.** The org copy (`org/page.tsx:785-921`) spaces bars by index and has an in-flight overlay. The dev copy (`dev/[login]/page.tsx:668-842`) spaces bars by date and has no overlay. +- **Fragile details.** Tooltip widths are guessed from character count (`org/page.tsx:524`, `dev/[login]/page.tsx:812`), so long labels overflow. `PieChart` computes `count / total` with no zero guard (`org/page.tsx:696`). Commit-type color maps are duplicated between the org and dev pages, and the dev copy lacks `in_flight`. +- **No tests for the report charts.** They live inside `page.tsx`, which may export only `default` (see CLAUDE.md), so nothing can import them. + +## Goals + +- Every data-encoding chart renders with Recharts v3, from modules outside `page.tsx`. +- Every chart is readable in all ten themes (six dark, four light). +- Key values and legends are readable without hovering. +- One `TimelineChart`, placed by date, with the in-flight overlay and hover synced across each page's grid. +- Behavioral tests for every chart, plus guard tests that stop literal colors from coming back. + +## Non-goals + +- API or data-shape changes. Charts consume the existing `timeline`, `TrendSeries[]` and `EpicRingStats` shapes. +- Migrating non-chart bars. The `ProjectsCard` volume bar (`src/components/ProjectsCard.tsx:208-262`, `~406-435`) and the sync progress bars (`src/app/reports/page.tsx:451-479`, `src/app/reports/vulnerability-syncs-tab.tsx:124-139`) stay as `
`s. Only their colors move to tokens. +- Mobile layout redesign beyond what responsive containers give for free. +- Click-to-select series, chart export, or table views. +- Moving to Tailwind v4 or adopting shadcn/ui beyond the one wrapper file. + +## Approaches considered + +| | Minimal: patch the hand-rolled charts | **Chosen: Recharts v3 + ported shadcn wrapper** | +|---|---|---| +| Work | ~1 day: CSS variables, `fill-*` overrides, dedupe `TimelineChart` | Shared chart layer, 6 chart rewrites (two `TimelineChart` copies become one), tokens, tests | +| Fixes light mode | Yes | Yes | +| Fixes sparse axes, hover-only values, tooltip overflow | No, each needs more hand-written geometry | Yes: tick algorithm, legends and tooltip come with the library | +| Long-term | Every new chart is bespoke again | New charts are composition | + +Libraries evaluated on 2026-09-25 against GitHub, npm and official docs: + +- **Recharts 3.10.1** (released 2026-07-25). Active, supports React 19, SVG, and covers every chart type used here. +- **Rejected:** + - **Tremor.** Stalled after the Vercel acquisition, and its peer dependency allows React 18 only. + - **Nivo.** Its core package hasn't had a release in 16 months. + - **ECharts.** Imperative API, a heavy bundle, and its heatmap strength isn't needed. + - **MUI X Charts.** Requires Emotion, and its heatmap is paid. + - **Chart.js.** Canvas-based, so it's hard to theme with CSS. + - **visx, Observable Plot, Unovis, uPlot.** Primitives, niche, or performance-focused. + +## Decisions + +1. **Single library.** Every chart that encodes data uses Recharts, including `TrendChart` and `ProgressRing`. UI chrome (progress bars, the inline volume bar) stays HTML. +2. **Recharts `^3.10`, with `clsx` and `tailwind-merge@^2.6`.** tailwind-merge 3.x supports Tailwind v4 only. Its README says: "if you use Tailwind v3, use tailwind-merge v2.6.0". +3. **Port shadcn's Tailwind-v3 `new-york` `chart.tsx`, not `new-york-v4`.** The v4 variant uses `outline-hidden` and `border-(--color-border)`, which are v4-only syntax. The v3 variant pins recharts 2.15.4, so the port applies Recharts 3 type changes, such as `TooltipProps` → `TooltipContentProps` ([migration guide](https://github.com/recharts/recharts/wiki/3.0-migration-guide)). +4. **`THEMES = { dark: "", light: '[data-theme-mode="light"]' }`.** The app's unscoped styles are dark, and light is the override. Renaming shadcn's `.dark` key directly would invert every chart. `applyTheme()` (`src/app/themes.ts:93-104`) sets `data-theme-mode` on ``. There is no system mode. +5. **No literal colors in chart code.** Every fill, stroke and text color comes from a CSS variable, defined under `:root` (dark) and `[data-theme-mode="light"]` (light). +6. **Scoped token names.** The wrapper's shadcn classes (`border-border`, `bg-background`, `text-muted-foreground`, `text-foreground`) are rewritten to a `chart.*` Tailwind color namespace, so the app theme gains no generic names. +7. **Single-metric timelines use `var(--accent)`,** so they follow the selected theme. Their in-flight segment is a diagonal hatch of the accent. This also removes the clash between today's in-flight cyan and Midnight Teal's accent. +8. **Multi-series charts use fixed semantic colors.** This covers commit types, lines added and removed, and the two `ProgressRing` arcs. Each has a dark step and a light step. Bug stays red and feature stays blue in every theme. +9. **In-flight is always hatched.** In multi-series charts, the hatch uses the in-flight token color. The pattern is a second visual cue, so in-flight is never shown by color alone. +10. **Bars are placed by date, and missing weeks are `null`.** `buildWeekDomain` returns every Monday-anchored week from the 90-day cutoff to the current week. A week with no data gets `null`, not `0`, because for averages such as lines per PR, "no PRs" isn't zero. Every chart on a page then shares the same x positions, which is what makes `syncId` work. +11. **`TrendChart` colors follow the team, not its rank.** `assignTeamColors(allTeamNames)` sorts the **unfiltered** team list by name and gives each team a fixed slot in the existing `--vuln-series-1..12` palette. A team keeps its color across syncs and filter changes. The only requirement from the product owner is that teams get distinct colors. This replaces GLOOK-43's rank-based assignment. + **Assumption:** there are at most 12 teams with alerts. If there are more, the 12 teams with the most open alerts get colors, the rest use `--vuln-series-other`, and the legend names them all. +12. **Diverging lines-changed chart.** Lines added go above zero and lines removed go below, with the in-flight part of each hatched. Today all four layers stack upward. +13. **The donut's legend always shows count and %.** When nothing is hovered, the center shows the total. Hovering a slice or legend row still moves that type's figures into the center and dims the other slices. +14. **Palette validation is a gate.** Every fixed palette passes the dataviz validator, once against the dark card surface and once against the light one: + - Adjacent-pair colorblind separation of ΔE ≥ 8. ΔE is perceived color distance in OKLab ×100. + - The normal-vision floor. + - Contrast. + + Tailwind hexes that fail move to the nearest passing shade. The validated values and the validator output go in this spec's [Palette](#palette) section during implementation. Each theme accent is checked against its theme's surface. A contrast warning is acceptable only because every timeline header prints its value as text. + +## Architecture + +### New modules + +| Module | Contents | +|---|---| +| `src/lib/cn.ts` | `cn(...inputs)`, using `clsx` and `tailwind-merge`. | +| `src/components/charts/chart.tsx` | The ported wrapper: `ChartContainer`, `ChartTooltip`, `ChartTooltipContent`, `ChartLegend`, `ChartLegendContent`, `ChartStyle`, and the `ChartConfig` type. | +| `src/components/charts/chart-format.ts` | Pure helpers: `toNum(v)`, `formatWeek(isoWeek)`, `formatValue(v, { suffix, decimals })`, and `buildWeekDomain(cutoff, today)`. They replace the per-chart inline closures. | +| `src/components/charts/commit-types.ts` | `COMMIT_TYPE_ORDER` (feature, bug, refactor, infra, docs, test, other, in_flight) and the type → token map. Unknown types map to `other`. It replaces `TYPE_HEX`/`TYPE_COLORS` in both pages. | +| `src/components/charts/hatch.tsx` | An SVG `` definition that takes a color variable, used for every in-flight segment. | +| `src/components/charts/timeline-chart.tsx` | The single `TimelineChart`. | +| `src/components/charts/stacked-types-chart.tsx` | `StackedTypesChart`. | +| `src/components/charts/lines-changed-chart.tsx` | `LinesChangedChart`. | +| `src/components/charts/commit-type-donut.tsx` | `CommitTypeDonut`, which replaces `PieChart`. | + +### Rewritten in place (import paths unchanged) + +- `src/app/vulnerabilities/trend-chart.tsx`, plus `assignTeamColors` next to it. +- `src/app/projects/progress-ring.tsx`. + +### Pages + +`report/[id]/org/page.tsx` and `report/[id]/dev/[login]/page.tsx` keep only data fetching and layout. They lose roughly 500 and 175 lines of drawing code, and still export only `default`. The `{timeline.length >= 2 && ...}` guards stay. + +## Theming + +### Tokens (`src/app/globals.css`) + +Each token is defined under `:root` (dark) and redefined under `[data-theme-mode="light"]`. + +- **Chrome:** `--chart-grid`, `--chart-axis`, `--chart-cursor`, `--chart-tooltip-bg`, `--chart-tooltip-border`, `--chart-tooltip-text`, `--chart-track`, `--chart-surface`. `--chart-surface` is used for the 2px gap between segments. +- **Commit types:** `--chart-type-feature`, `-bug`, `-refactor`, `-infra`, `-docs`, `-test`, `-other`, `-in-flight`. +- **Lines:** `--chart-lines-added`, `--chart-lines-removed`. +- **Ring:** `--chart-ring-jira`, `--chart-ring-commits`. +- **Reused unchanged:** `--vuln-series-1..12`, `--vuln-series-other`, `--accent`. + +`tailwind.config.ts` registers the chrome tokens under `theme.extend.colors.chart`, for example `chart-axis`. + +### Palette + +*Filled in during implementation with validated dark and light hex values and the validator's output (Decision 14).* + +### Mark specs (from the dataviz skill) + +- Bars have a 4px rounded top on the top segment only, and a 2px `--chart-surface` gap between stacked segments. +- The grid is horizontal only and recessive. Axis lines are hidden or faint. +- Trend lines are 2px, and hover markers are at least 8px. +- Text always uses the chrome text tokens, never the series color. +- Recharts' default `accessibilityLayer` stays on. + +## Charts + +**`TimelineChart`** (5 uses on org, 6 on dev, in `grid-cols-1 md:grid-cols-2` grids) + +- **Props:** + - `data`, and `valueKey` or `computeValue`, as used for the dev page's lines changed (`linesAdded + linesRemoved`). + - `label`, `suffix`, `decimals`. + - `inFlightValue?`. Today the org page passes it for commits, as `d.types?.in_flight ?? 0`. + - `syncId`. +- **Keeps:** + - The header, with the latest value and the change from the previous week. "Latest" means the last week with data. + - The 90-day cutoff. + - Weekly buckets. +- **Changes:** + - Date placement (Decision 10). + - The accent fill with the in-flight hatch. + - Recharts ticks with `minTickGap` and rounded y ticks. + - A tooltip showing the week, the value, and the shipped and in-flight split when present. + - `syncId="org-timeline"` on the org page and `"dev-timeline"` on the dev page. + +**`LinesChangedChart`** (org) + +- **Keeps:** the `linesP95Added/Removed` and `inFlightLinesP95Added/Removed` fields. +- **Changes:** the diverging layout (Decision 12). Colors are the lines tokens, with in-flight hatched. + +**`StackedTypesChart`** (org) + +- **Keeps:** + - One stacked bar per week, in `COMMIT_TYPE_ORDER`. + - The static legend. + - The per-type tooltip breakdown. + - The 90-day cutoff. +- **Changes:** type tokens, with in-flight hatched. + +**`CommitTypeDonut`** (org) + +- **Keeps:** `entries` and `total` computed on the page. In-flight is already merged into `types` on the server (`org/page.tsx:88-90`). +- **Behavior:** Decision 13. +- **Changes:** + - A square responsive container, at most 320px wide, replaces `min(320px, 50cqw)`. + - A total of 0 renders the empty state "No categorized commits". + +**`TrendChart`** (vulnerabilities) + +- **Form:** a `LineChart` with 2px lines, automatic date ticks, and a crosshair tooltip listing teams by value. +- **Keeps:** hovering a line or legend entry fades the other series. +- **Colors:** Decision 11. The plan must confirm whether the component ever receives filtered series. If it does, the caller passes the unfiltered team list in. + +**`ProgressRing`** (projects) + +- **Form:** a fixed-size `RadialBarChart` with no responsive container. +- **Keeps:** + - The `Math.log`-based size of 22-48px. + - Stroke width that scales inversely with size. + - Jira on the outer ring, commits on the inner ring, and `devCount` in the center. + - No divide-by-zero when `maxVolume = 0`. + +### Every chart + +- An explicit empty state. +- Every numeric value passes through `toNum()`, because `DECIMAL`/`REAL` columns may arrive as strings (CLAUDE.md gotcha). +- Charts never throw. `NaN` renders as 0. + +## Testing + +Tests go in `src/lib/__tests__/unit/` (Jest `roots` is `src/lib`) and use the `/** @jest-environment jsdom */` docblock. A shared helper stubs `ResizeObserver`, which jsdom lacks. If Recharts 3 supports `ResponsiveContainer`'s `initialDimension`, tests may use that instead. The plan must confirm it. + +**Behavior tests:** + +| Subject | Asserts | +|---|---| +| `chart-format` | Weeks are Monday-anchored; missing weeks are `null`, never `0`; `toNum("12.50") === 12.5`; the formatter respects `suffix` and `decimals` | +| `TimelineChart` | One bar per week with data, and gaps for `null`; the header shows the latest value and change; the hatch appears only with `inFlightValue`; bars use `var(--accent)` | +| `CommitTypeDonut` | The legend shows count and % with no hover; a total of 0 shows the empty state; hovering changes the center label | +| `LinesChangedChart` | Removed lines render below the zero baseline | +| `TrendChart` | Colors are distinct; a team keeps its color after filtering and after a rank swap | +| `ProgressRing` | The existing size, stroke and zero-volume guarantees, rewritten against the new markup | + +**Guard tests:** + +1. **No literal colors.** Chart modules contain no hex color and no `fill-gray-*`/`stroke-gray-*` class. It's a static scan in the style of `logger-enforcement.test.ts`. +2. **Tokens in both modes.** Every `--chart-*` token is defined under both `:root` and `[data-theme-mode="light"]`, in the style of `vuln-trend-colors-css.test.ts`. +3. **Contrast.** A test for the new fixed palettes, in the style of `vuln-series-contrast.test.ts`. + +`vuln-trend-chart.test.tsx` and `progress-ring.test.tsx` are rewritten. Their rank-based and ``-count assertions describe the old implementation. + +**Verification before done:** + +- The full Jest suite passes. +- `npm run build` passes, which catches extra `page.tsx` exports. +- The mock-mode app (`npm run dev:mock`) is screenshotted on the org, dev, vulnerabilities and projects pages. That's done in one dark theme (Amber Glow) and one light theme (Daylight Blue). The screenshots are checked for label collisions, clipping and legibility. + +## Risks + +- **Recharts 3 details found only in the migration guide are unverified.** These are the `CartesianGrid` axis IDs and the `ResponsiveContainer` ref changes. They're confirmed during the wrapper port. +- **Hover sync depends on identical week arrays.** `syncId` matches on index. `buildWeekDomain` guarantees identical arrays within a page, and a test asserts it. +- **More than 12 vulnerability teams** falls back per Decision 11. That's acceptable but not ideal. From acad6055435bbe9f38ee82b9337b7cb5406b3b2d Mon Sep 17 00:00:00 2001 From: Evgeniy Nepeipivo Date: Fri, 25 Sep 2026 10:10:02 -0400 Subject: [PATCH 02/53] GLOOK-58: spec review loop 1 fixes Filtered-series colour contract, count vs ratio week filling, hatch mechanics with per-instance pattern ids, signed-stack diverging bars, fixed ProgressRing angle domain, split palette validation, global ResizeObserver stub, third type-colour map in team/dev-table. Co-Authored-By: Claude Opus 5.5 --- ...9-25-glook-58-recharts-migration-design.md | 110 +++++++++++++----- 1 file changed, 83 insertions(+), 27 deletions(-) diff --git a/docs/superpowers/specs/2026-09-25-glook-58-recharts-migration-design.md b/docs/superpowers/specs/2026-09-25-glook-58-recharts-migration-design.md index c3ce07ae..7c5057b8 100644 --- a/docs/superpowers/specs/2026-09-25-glook-58-recharts-migration-design.md +++ b/docs/superpowers/specs/2026-09-25-glook-58-recharts-migration-design.md @@ -61,18 +61,32 @@ Libraries evaluated on 2026-09-25 against GitHub, npm and official docs: 6. **Scoped token names.** The wrapper's shadcn classes (`border-border`, `bg-background`, `text-muted-foreground`, `text-foreground`) are rewritten to a `chart.*` Tailwind color namespace, so the app theme gains no generic names. 7. **Single-metric timelines use `var(--accent)`,** so they follow the selected theme. Their in-flight segment is a diagonal hatch of the accent. This also removes the clash between today's in-flight cyan and Midnight Teal's accent. 8. **Multi-series charts use fixed semantic colors.** This covers commit types, lines added and removed, and the two `ProgressRing` arcs. Each has a dark step and a light step. Bug stays red and feature stays blue in every theme. -9. **In-flight is always hatched.** In multi-series charts, the hatch uses the in-flight token color. The pattern is a second visual cue, so in-flight is never shown by color alone. -10. **Bars are placed by date, and missing weeks are `null`.** `buildWeekDomain` returns every Monday-anchored week from the 90-day cutoff to the current week. A week with no data gets `null`, not `0`, because for averages such as lines per PR, "no PRs" isn't zero. Every chart on a page then shares the same x positions, which is what makes `syncId` work. -11. **`TrendChart` colors follow the team, not its rank.** `assignTeamColors(allTeamNames)` sorts the **unfiltered** team list by name and gives each team a fixed slot in the existing `--vuln-series-1..12` palette. A team keeps its color across syncs and filter changes. The only requirement from the product owner is that teams get distinct colors. This replaces GLOOK-43's rank-based assignment. - **Assumption:** there are at most 12 teams with alerts. If there are more, the 12 teams with the most open alerts get colors, the rest use `--vuln-series-other`, and the legend names them all. -12. **Diverging lines-changed chart.** Lines added go above zero and lines removed go below, with the in-flight part of each hatched. Today all four layers stack upward. -13. **The donut's legend always shows count and %.** When nothing is hovered, the center shows the total. Hovering a slice or legend row still moves that type's figures into the center and dims the other slices. -14. **Palette validation is a gate.** Every fixed palette passes the dataviz validator, once against the dark card surface and once against the light one: - - Adjacent-pair colorblind separation of ΔE ≥ 8. ΔE is perceived color distance in OKLab ×100. - - The normal-vision floor. - - Contrast. - - Tailwind hexes that fail move to the nearest passing shade. The validated values and the validator output go in this spec's [Palette](#palette) section during implementation. Each theme accent is checked against its theme's surface. A contrast warning is acceptable only because every timeline header prints its value as text. +9. **In-flight is always hatched.** This holds in every chart that shows it: the single-metric timelines, `StackedTypesChart`, `LinesChangedChart`, and the donut's `in_flight` wedge and legend swatch. In multi-series charts the hatch uses the in-flight token color, and in single-metric timelines it uses the accent. The pattern is a second visual cue, so in-flight is never shown by color alone. The mechanics are in [Hatch](#hatch). +10. **Bars are placed by date, and the fill depends on the metric kind.** `aggregateWeekly` (`src/lib/report/timeline.ts:135`) emits **only weeks that have commits**. Its ratios come back as `0` when the denominator is zero, for example `avgLinesPerPr` at `:142`. `buildWeekDomain` returns every Monday-anchored week from the 90-day cutoff to the current week. `TimelineChart` then fills it according to a `kind` prop: + - **`kind: 'count'`** covers commits, PRs, lines changed and in-flight commits. A week absent from `data` is a real zero, so it is filled with `0`. + - **`kind: 'ratio'`** covers `avgLinesPerPr`, `avgImpact`, `avgComplexity` and `aiPercent`. A week absent from `data` has no defined value, so it is filled with `null` and renders as a gap. + - For `avgLinesPerPr`, a present week with `prs === 0` is also `null`, via an optional `isDefined(d)` prop. The payload carries `prs`, so this needs no API change. + - `avgComplexity` and `avgImpact` expose no denominator, so a present week reporting `0` is drawn as 0. See [Risks](#risks). + + Every chart on a page shares the same week array, which is what makes `syncId` work. Recharts' default `syncMethod="index"` matches on array index. +11. **`TrendChart` colors follow the team, not its rank.** The caller, `vulnerabilities-content.tsx`, builds `colorByTeam = assignTeamColors(teamNames)` from the **unfiltered** `trend.series`. It must do this before applying the team filter at `:202-203`, and it passes the map in as a prop. `assignTeamColors` sorts the names and gives each team a fixed slot in the existing `--vuln-series-1..12` palette. A team then keeps its color across syncs and when the filter narrows the chart. The only requirement from the product owner is that teams get distinct colors. This replaces GLOOK-43's rank-based assignment. + **Assumption:** there are at most 12 teams with alerts. If there are more, the 12 teams with the most open alerts (ties broken by name) are chosen. Those 12 are then sorted by name into slots 1..12. The rest use `--vuln-series-other`, and the legend names them all. In this fallback, a team entering or leaving the top 12 can shift other teams' slots. +12. **Diverging lines-changed chart.** Lines added go above zero and lines removed go below, in a single stack with `stackOffset="sign"`. Removed values are negated. The in-flight part of each is hatched. A `ReferenceLine y={0}` draws the zero baseline in full `--chart-axis`, because on this chart the baseline carries meaning and isn't just a recessive gridline. Today all four layers stack upward. +13. **The donut's legend always shows count and %.** When nothing is hovered, the center shows the total. Hovering a slice or legend row still moves that type's figures into the center and dims the other slices. The center label is always chrome text (`--chart-tooltip-text`/`--chart-axis`). Type identity comes from a small color swatch beside it. Today the label is drawn in the series color (`style={{ fill: TYPE_HEX[...] }}`), which breaks the text rule. +14. **Palette validation is a gate, with two paths.** The validated values and the validator output go in this spec's [Palette](#palette) section. Producing them is the **first** implementation task, and every chart task depends on it. + - **Fixed multi-color palettes** (commit types, lines, ring, and `--vuln-series-*` already validated) are checked against the dark card surface and the light card surface: + - Adjacent-pair colorblind separation of ΔE ≥ 8. ΔE is perceived color distance in OKLab ×100. + - The normal-vision floor. + - 3:1 graphics contrast. + + Tailwind hexes that fail move to the nearest passing shade. The ring colors are additionally checked at 3:1 against `--chart-track`, the surface they're drawn over. + - **`var(--accent)`** is a single color per theme and never appears beside another accent. So it gets only 3:1 graphics contrast against its own theme's card surface, for all ten themes. Colorblind separation between accents is meaningless. + - **Chrome tokens:** + - `--chart-axis` meets 4.5:1 text contrast against the card surface. + - `--chart-tooltip-text` meets 4.5:1 against `--chart-tooltip-bg`. + - `--chart-grid` and `--chart-tooltip-border` meet 3:1 against their own backgrounds. + + Each is checked in both modes. ## Architecture @@ -83,8 +97,9 @@ Libraries evaluated on 2026-09-25 against GitHub, npm and official docs: | `src/lib/cn.ts` | `cn(...inputs)`, using `clsx` and `tailwind-merge`. | | `src/components/charts/chart.tsx` | The ported wrapper: `ChartContainer`, `ChartTooltip`, `ChartTooltipContent`, `ChartLegend`, `ChartLegendContent`, `ChartStyle`, and the `ChartConfig` type. | | `src/components/charts/chart-format.ts` | Pure helpers: `toNum(v)`, `formatWeek(isoWeek)`, `formatValue(v, { suffix, decimals })`, and `buildWeekDomain(cutoff, today)`. They replace the per-chart inline closures. | -| `src/components/charts/commit-types.ts` | `COMMIT_TYPE_ORDER` (feature, bug, refactor, infra, docs, test, other, in_flight) and the type → token map. Unknown types map to `other`. It replaces `TYPE_HEX`/`TYPE_COLORS` in both pages. | -| `src/components/charts/hatch.tsx` | An SVG `` definition that takes a color variable, used for every in-flight segment. | +| `src/components/charts/commit-types.ts` | `COMMIT_TYPE_ORDER` (feature, bug, refactor, infra, docs, test, other, in_flight) and the type → token map. Unknown types map to `other`. It replaces all three copies of the map: `TYPE_HEX`/`TYPE_COLORS` in the org page, `TYPE_COLORS`/`TYPE_TEXT_COLORS` in the dev page, and `TYPE_COLORS` in `src/app/report/[id]/team/dev-table.tsx:27`. Otherwise the palette gate could move a hue in the charts while the team page's badges keep the old one. | +| `src/components/charts/hatch.tsx` | `useHatch(colorVar)` returns `{ id, defs }`. The pattern is described under [Hatch](#hatch). | +| `src/lib/__tests__/setup/resize-observer.ts` | A jsdom-only `ResizeObserver` stub, guarded by `typeof window !== 'undefined'`. It's registered through a new `setupFiles` entry in `jest.config.ts`. It has to be global because four existing tests (`vuln-content-trend-range`, `-team-dropdown`, `-repo-reset`, `-error`) render `VulnerabilitiesContent` without mocking `TrendChart`. Any of them that reaches the chart would break as soon as it uses a responsive container. | | `src/components/charts/timeline-chart.tsx` | The single `TimelineChart`. | | `src/components/charts/stacked-types-chart.tsx` | `StackedTypesChart`. | | `src/components/charts/lines-changed-chart.tsx` | `LinesChangedChart`. | @@ -95,6 +110,16 @@ Libraries evaluated on 2026-09-25 against GitHub, npm and official docs: - `src/app/vulnerabilities/trend-chart.tsx`, plus `assignTeamColors` next to it. - `src/app/projects/progress-ring.tsx`. +### Other files touched + +- **`src/app/vulnerabilities/vulnerabilities-content.tsx`.** Builds `colorByTeam` from the unfiltered series (Decision 11). +- **`src/app/report/[id]/team/dev-table.tsx`.** Its type badges read `commit-types.ts`. +- **Non-chart bars, colors only.** `src/components/ProjectsCard.tsx`, `src/app/reports/page.tsx` and `src/app/reports/vulnerability-syncs-tab.tsx`. +- **Config and dependencies.** + - `src/app/globals.css` (tokens) and `tailwind.config.ts` (`chart.*` colors). + - `jest.config.ts` (`setupFiles`). + - `package.json`: `recharts`, `clsx`, `tailwind-merge@^2.6`. + ### Pages `report/[id]/org/page.tsx` and `report/[id]/dev/[login]/page.tsx` keep only data fetching and layout. They lose roughly 500 and 175 lines of drawing code, and still export only `default`. The `{timeline.length >= 2 && ...}` guards stay. @@ -115,12 +140,22 @@ Each token is defined under `:root` (dark) and redefined under `[data-theme-mode ### Palette -*Filled in during implementation with validated dark and light hex values and the validator's output (Decision 14).* +*Filled in by the first implementation task: the validated dark and light hex values, and the validator's output (Decision 14). No chart task starts before this section is complete.* + +### Hatch + +An in-flight segment is its own rect, wedge or swatch. Its `fill` is `url(#)` **instead of** a solid color. It is never drawn on top of a solid mark of the same color, because a same-color pattern over a same-color fill is invisible. + +The pattern itself: +- It has an explicit `--chart-surface` background, never a transparent one. +- It draws diagonal strokes in the given color variable, about 2px wide at 45°. + +Each chart instance gets its own pattern ID from React `useId()`. A page shows up to six charts in one document-wide ID space, so a static `#hatch` would make the accent pattern and the in-flight-token pattern collide. ### Mark specs (from the dataviz skill) - Bars have a 4px rounded top on the top segment only, and a 2px `--chart-surface` gap between stacked segments. -- The grid is horizontal only and recessive. Axis lines are hidden or faint. +- The grid is horizontal only and recessive. Axis lines are hidden or faint. The one exception is the diverging chart's zero line (Decision 12). - Trend lines are 2px, and hover markers are at least 8px. - Text always uses the chrome text tokens, never the series color. - Recharts' default `accessibilityLayer` stays on. @@ -131,6 +166,7 @@ Each token is defined under `:root` (dark) and redefined under `[data-theme-mode - **Props:** - `data`, and `valueKey` or `computeValue`, as used for the dev page's lines changed (`linesAdded + linesRemoved`). + - `kind: 'count' | 'ratio'`, plus an optional `isDefined(d)`, per Decision 10. - `label`, `suffix`, `decimals`. - `inFlightValue?`. Today the org page passes it for commits, as `d.types?.in_flight ?? 0`. - `syncId`. @@ -164,18 +200,22 @@ Each token is defined under `:root` (dark) and redefined under `[data-theme-mode - **Keeps:** `entries` and `total` computed on the page. In-flight is already merged into `types` on the server (`org/page.tsx:88-90`). - **Behavior:** Decision 13. - **Changes:** - - A square responsive container, at most 320px wide, replaces `min(320px, 50cqw)`. + - `` inside a `max-w-[320px]` wrapper replaces `min(320px, 50cqw)`. + - The `in_flight` wedge and its legend swatch are hatched (Decision 9). - A total of 0 renders the empty state "No categorized commits". **`TrendChart`** (vulnerabilities) - **Form:** a `LineChart` with 2px lines, automatic date ticks, and a crosshair tooltip listing teams by value. - **Keeps:** hovering a line or legend entry fades the other series. -- **Colors:** Decision 11. The plan must confirm whether the component ever receives filtered series. If it does, the caller passes the unfiltered team list in. +- **Props:** `TrendChart({ series, colorByTeam })`. The chart no longer computes colors itself, because `series` may already be filtered (Decision 11). **`ProgressRing`** (projects) - **Form:** a fixed-size `RadialBarChart` with no responsive container. + - It sets ``, `startAngle={90}` and `endAngle={-270}`, so each ring starts at the top and fills clockwise, as a share of 100%. + - Without the fixed domain, `RadialBar` scales to the largest value in its data. At 40% Jira and 20% commits, the 40% ring would draw as a full circle. + - Each ring's track is its `background` in `--chart-track`. - **Keeps:** - The `Math.log`-based size of 22-48px. - Stroke width that scales inversely with size. @@ -190,24 +230,33 @@ Each token is defined under `:root` (dark) and redefined under `[data-theme-mode ## Testing -Tests go in `src/lib/__tests__/unit/` (Jest `roots` is `src/lib`) and use the `/** @jest-environment jsdom */` docblock. A shared helper stubs `ResizeObserver`, which jsdom lacks. If Recharts 3 supports `ResponsiveContainer`'s `initialDimension`, tests may use that instead. The plan must confirm it. +Tests go in `src/lib/__tests__/unit/` (Jest `roots` is `src/lib`) and use the `/** @jest-environment jsdom */` docblock. +- **Sizing.** The global `ResizeObserver` stub (see [Architecture](#new-modules)) keeps responsive containers from failing. Chart tests also pass a fixed size, either through `ResponsiveContainer`'s `initialDimension`, which is confirmed to exist in Recharts 3, or an explicit `width`/`height`. +- **Text layout.** jsdom has no SVG text layout: `getBoundingClientRect` and `getComputedTextLength` return 0. So tests assert on **data, props, fills and ARIA labels**. They never assert on tick counts, tick positions or label placement. Those are left to the screenshot pass. **Behavior tests:** | Subject | Asserts | |---|---| -| `chart-format` | Weeks are Monday-anchored; missing weeks are `null`, never `0`; `toNum("12.50") === 12.5`; the formatter respects `suffix` and `decimals` | -| `TimelineChart` | One bar per week with data, and gaps for `null`; the header shows the latest value and change; the hatch appears only with `inFlightValue`; bars use `var(--accent)` | -| `CommitTypeDonut` | The legend shows count and % with no hover; a total of 0 shows the empty state; hovering changes the center label | -| `LinesChangedChart` | Removed lines render below the zero baseline | -| `TrendChart` | Colors are distinct; a team keeps its color after filtering and after a rank swap | -| `ProgressRing` | The existing size, stroke and zero-volume guarantees, rewritten against the new markup | +| `chart-format` | Weeks are Monday-anchored; for `kind: 'count'` a missing week is `0`, and for `kind: 'ratio'` a missing week is `null`; `isDefined` returning false gives `null`; every chart on a page gets an identical week array; `toNum("12.50") === 12.5`; the formatter respects `suffix` and `decimals` | +| `TimelineChart` | One bar per week that has a value, and gaps for `null`; the header shows the latest value and change; the hatch appears only with `inFlightValue`; bars use `var(--accent)`; two instances get different pattern IDs | +| `CommitTypeDonut` | The legend shows count and % with no hover; a total of 0 shows the empty state; hovering changes the center label, which stays chrome-colored; the `in_flight` wedge uses a pattern fill | +| `LinesChangedChart` | Removed lines render below the zero baseline, and the zero reference line is present | +| `TrendChart` | Colors are distinct; a team keeps its color after filtering and after a rank swap (tested through `assignTeamColors` and the caller) | +| `ProgressRing` | The existing size, stroke and zero-volume guarantees, rewritten against the new markup; the angle domain is fixed at 0-100, so 40% doesn't draw as a full ring | **Guard tests:** 1. **No literal colors.** Chart modules contain no hex color and no `fill-gray-*`/`stroke-gray-*` class. It's a static scan in the style of `logger-enforcement.test.ts`. 2. **Tokens in both modes.** Every `--chart-*` token is defined under both `:root` and `[data-theme-mode="light"]`, in the style of `vuln-trend-colors-css.test.ts`. -3. **Contrast.** A test for the new fixed palettes, in the style of `vuln-series-contrast.test.ts`. +3. **Contrast.** In the style of `vuln-series-contrast.test.ts`, this test covers every threshold in Decision 14: + - The fixed palettes at 3:1 against the card surface in each mode. + - The ring colors at 3:1 against `--chart-track`. + - All ten theme accents at 3:1 against their own mode's surface. + - `--chart-axis` at 4.5:1 against the card, and `--chart-tooltip-text` at 4.5:1 against `--chart-tooltip-bg`. + - `--chart-grid` and `--chart-tooltip-border` at 3:1. + + Colorblind separation is checked by the dataviz validator during the palette task, and its output is recorded in [Palette](#palette). `vuln-trend-chart.test.tsx` and `progress-ring.test.tsx` are rewritten. Their rank-based and ``-count assertions describe the old implementation. @@ -215,10 +264,17 @@ Tests go in `src/lib/__tests__/unit/` (Jest `roots` is `src/lib`) and use the `/ - The full Jest suite passes. - `npm run build` passes, which catches extra `page.tsx` exports. -- The mock-mode app (`npm run dev:mock`) is screenshotted on the org, dev, vulnerabilities and projects pages. That's done in one dark theme (Amber Glow) and one light theme (Daylight Blue). The screenshots are checked for label collisions, clipping and legibility. +- The mock-mode app (`npm run dev:mock`) is screenshotted on the org, dev, vulnerabilities and projects pages. That's done in one dark theme (Amber Glow) and one light theme (Daylight Blue). The screenshots are checked for: + - Label collisions, clipping and legibility. + - Whether the hatch is legible on narrow bars. ## Risks - **Recharts 3 details found only in the migration guide are unverified.** These are the `CartesianGrid` axis IDs and the `ResponsiveContainer` ref changes. They're confirmed during the wrapper port. +- **Negative bars in a `stackOffset="sign"` stack.** + - Recharts [#6802](https://github.com/recharts/recharts/issues/6802) reported that negative values in a signed stack failed to render. The fix, PR #6806, was merged on 2025-12-19, before 3.10.1 shipped on 2026-07-25. + - The wrapper task confirms this in the release notes. The "removed renders below zero" test catches any regression. + - Two separate `stackId`s are **not** a fallback. They would place added and removed side by side instead of above and below one baseline. - **Hover sync depends on identical week arrays.** `syncId` matches on index. `buildWeekDomain` guarantees identical arrays within a page, and a test asserts it. +- **`avgComplexity` and `avgImpact` can't tell "no data" from a real 0** within a week that is present, because the payload exposes no denominator (Decision 10). Fixing this needs an API change, which is out of scope. - **More than 12 vulnerability teams** falls back per Decision 11. That's acceptable but not ideal. From 232aa74fcb939a2012d935dc1fd6f80cee59b842 Mon Sep 17 00:00:00 2001 From: Evgeniy Nepeipivo Date: Fri, 25 Sep 2026 10:44:08 -0400 Subject: [PATCH 03/53] GLOOK-58: implementation plan Co-Authored-By: Claude Opus 5.5 --- .../2026-09-25-glook-58-recharts-migration.md | 3824 +++++++++++++++++ 1 file changed, 3824 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-25-glook-58-recharts-migration.md diff --git a/docs/superpowers/plans/2026-09-25-glook-58-recharts-migration.md b/docs/superpowers/plans/2026-09-25-glook-58-recharts-migration.md new file mode 100644 index 00000000..a48b6a4e --- /dev/null +++ b/docs/superpowers/plans/2026-09-25-glook-58-recharts-migration.md @@ -0,0 +1,3824 @@ +# GLOOK-58 Recharts v3 Chart Migration Implementation Plan + +> **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:** Replace every hand-rolled SVG chart with Recharts v3 components that live outside `page.tsx`, read all colors from theme-aware CSS tokens, and have behavioral tests. + +**Architecture:** A ported shadcn `chart.tsx` wrapper sits on Recharts 3.10. Pure helpers (`chart-format.ts`, `commit-types.ts`) and a hatch-pattern hook (`hatch.tsx`) sit under it. Five chart components sit on top in `src/components/charts/`, and `TrendChart` and `ProgressRing` are rewritten in place. Every color is a `--chart-*` CSS variable defined for dark under `:root` and for light under `[data-theme-mode="light"]`. Three guard tests stop literal colors, missing tokens and contrast regressions from coming back. + +**Tech Stack:** Next.js 15 App Router, React 19.2, TypeScript, Tailwind CSS 3.4, Recharts 3.10.1, clsx 2, tailwind-merge 2.6, Jest 30 + ts-jest + jsdom 26, @testing-library/react 16. + +**Spec:** `docs/superpowers/specs/2026-09-25-glook-58-recharts-migration-design.md`. Read it fully before Task 1. The plan argues from it, and where the two disagree the plan says so and says why. + +## Global Constraints + +- **Run every node command through Node 24.** The machine default (Node 26) breaks `better-sqlite3`. Always use this form from the worktree root, with one plain command inside the quotes: no `$VARS`, no `&&`, no pipes. The harness refuses compound payloads. + `env PATH="/opt/homebrew/Cellar/node@24/24.16.0/bin:/usr/bin:/bin:/usr/sbin:/sbin" sh -c ''` +- **Worktree:** `/Users/maes/Documents/1macmount/code/glooker/.claude/worktrees/GLOOK-58-recharts`. Work only here. Git works normally in this worktree (`git status` returns instantly). Use `git add `, never `git add -A` or `git add .`. +- **Test baseline:** 169 suites / 1669 tests green. Single file: `npx jest `. Full suite: `npx jest --maxWorkers=3`. +- **Commit messages** start with `GLOOK-58: ` and end with a blank line followed by `Co-Authored-By: Claude Opus 5.5 `. Write each message with a heredoc: `git commit -F - <<'EOF' ... EOF`. +- **Dependency versions (spec Decision 2):** `recharts@^3.10`, `clsx@^2.1`, `tailwind-merge@^2.6`. Do **not** use tailwind-merge 3.x, which supports Tailwind v4 only. Also add `react-is@^19.2` (see Task 2 for why). +- **`src/app/**/page.tsx` may export only `default`.** An extra export passes `npm test` and `tsc` and fails only at `npm run build`. Charts and their helpers live in `src/components/charts/`. +- **Tests live flat in `src/lib/__tests__/unit/`** (Jest `roots` is `src/lib`). Component tests start with `/** @jest-environment jsdom */` on line 1. `@testing-library/jest-dom` is **not** installed, so use plain Jest matchers (`toBe`, `toBeTruthy`, `toBeNull`, `toEqual`). +- **Tests check behavior a user sees:** rendered values, fills, ARIA, legend and tooltip text, counts of marks. They never assert tick counts, tick positions or label placement, because jsdom has no SVG text layout. +- **Numeric inputs pass through `toNum()`** (from `chart-format.ts`). `DECIMAL`/`REAL` columns can arrive as strings. Charts never throw, and `NaN` renders as 0. +- **No literal colors in chart modules** (spec Decision 5). No hex color, no `fill-gray-*` or `stroke-gray-*` class. That covers `src/components/charts/*`, `src/app/vulnerabilities/trend-chart.tsx`, `src/app/vulnerabilities/team-colors.ts` and `src/app/projects/progress-ring.tsx`. Do not write `#` followed by three or more hex digits in those files' comments either, for example an issue reference like `#6806`: the guard in Task 10 reads it as a color. +- **Every `--chart-*` token value is a 6-digit hex** (`#rrggbb`) in both blocks. The contrast guard only parses that form. +- **Text in charts uses chrome tokens** (`text-chart-axis`, `text-chart-tooltip-text`, `fill-chart-axis`), never a series color. +- **Every Recharts series sets `isAnimationActive={false}`.** Two reasons: + 1. The org page has a Download PDF button, and `window.print()` would capture bars mid-animation. + 2. jsdom has no `matchMedia`, so Recharts' `'auto'` animation stays on in tests. A bar's first frame has zero height, and a zero-height `Rectangle` renders nothing. +- **The repo is public OSS.** No company-internal names (teams, repos, people, org structure) in code, tests, fixtures, docs or commit messages. Use generic names: `acme`, `TeamA`, `team.alpha`. +- **Find edits by the quoted code, not by line number.** Line numbers are as of the start of the plan (commit `acad605`). An earlier step in the same task, such as an added import, shifts them. +- **Do not touch unrelated code.** Each task lists its files. If a change seems to need a file that isn't listed, stop and ask. + +## Review Focus + +1. **Week keys that are not Mondays.** `weekKeyForDate()` (`src/lib/report/timeline.ts:24-29`) sets the date to Monday in local time but keeps the commit's time of day, then formats the result in UTC. A commit on Monday at 21:00 in New York gets a **Tuesday** key, and in a UTC+ zone a Sunday key is possible. One real week can then arrive as two buckets. If the week domain matched keys exactly, those weeks would render as false zeros, silently losing data. `snapWeekKey()` snaps every key to its nearest Monday and merges the rows. Pinned in Task 3 (`chart-format.test.ts`) and Task 4 (a Tuesday-keyed row still draws a bar). +2. **Numbers that arrive as strings.** A `"12.50"` or `""` from a DECIMAL column must render as 12.5 or 0, never as `NaN` or string concatenation. Pinned in Task 3 (`toNum`) and Task 4 (a string-valued ratio renders its header value). +3. **All-zero and zero-total inputs.** These must show an explicit empty state, not an empty frame or `NaN%`: + - A donut total of 0, or `"0"`. + - A lines chart whose weeks are all zero. + - A timeline with no data in 90 days. + - A ring with `maxVolume = 0`. + + Pinned in Tasks 4, 5 and 8. +4. **Team names containing `.` or spaces.** Recharts treats a string `dataKey` as a lodash-style path, so a team named `team.alpha` would read `row.team.alpha` and draw nothing. `TrendChart` uses a function `dataKey`. Pinned in Task 7. +5. **In-flight values larger than the week's total.** If the overlay claims more in-flight commits than the week has, shipped would go negative and the stack would draw below zero. The timeline clamps in-flight to `[0, total]`. Pinned in Task 4. + +--- + +## File Structure + +| File | Responsibility | Task | +|---|---|---| +| `src/app/globals.css` | `--chart-*` tokens for both modes, plus light and print rules for type-badge classes | 1, 6 | +| `tailwind.config.ts` | `chart.*` color namespace | 1 | +| `src/lib/cn.ts` | `cn()` class merger | 2 | +| `src/lib/__tests__/setup/resize-observer.ts` | jsdom-only `ResizeObserver` stub (`setupFiles`) | 2 | +| `src/lib/__tests__/setup/chart-size.ts` | `fixChartSize()` test helper | 2 | +| `jest.config.ts` | `setupFiles` entry | 2 | +| `src/components/charts/chart.tsx` | Ported shadcn wrapper | 2 | +| `src/components/charts/chart-format.ts` | `toNum`, week domain, snapping, filling, formatters, `isTopOfStack` | 3 | +| `src/components/charts/commit-types.ts` | Type order, color and background-class maps, `foldTypes` | 3 | +| `src/components/charts/hatch.tsx` | `useHatch()`, `HatchSwatch` | 3 | +| `src/components/charts/timeline-chart.tsx` | `TimelineChart`, `TimelineTooltip` | 4 | +| `src/components/charts/stacked-types-chart.tsx` | `StackedTypesChart`, `StackedTypesTooltip` | 5 | +| `src/components/charts/lines-changed-chart.tsx` | `LinesChangedChart`, `LinesTooltip` | 5 | +| `src/components/charts/commit-type-donut.tsx` | `CommitTypeDonut` | 5 | +| `src/app/report/[id]/org/page.tsx` | Wiring only | 4, 5 | +| `src/app/report/[id]/dev/[login]/page.tsx` | Wiring only | 4, 6 | +| `src/app/report/[id]/team/dev-table.tsx` | Type badges read `commit-types.ts` | 6 | +| `src/app/vulnerabilities/team-colors.ts` | `assignTeamColors`, `OTHER_TEAM_COLOR` | 7 | +| `src/app/vulnerabilities/trend-chart.tsx` | Rewritten `TrendChart` | 7 | +| `src/app/vulnerabilities/vulnerabilities-content.tsx` | Passes `colorByTeam` | 7 | +| `src/app/projects/progress-ring.tsx` | Rewritten `ProgressRing`, `ringGeometry` | 8 | +| `src/components/ProjectsCard.tsx`, `src/app/reports/page.tsx`, `src/app/reports/vulnerability-syncs-tab.tsx` | Colors only | 9 | + +**Two deliberate additions to the spec's interfaces, both needed by its own requirements:** + +1. **Every page-grid chart takes a `weeks: string[]` prop.** The page computes it once with `recentWeekDomain(new Date())`. Decision 10 says every chart on a page shares the same week array, because `syncId` matches on index. Computing the domain once per page render guarantees that, even across midnight. +2. **`fillWeeks()` and `groupByWeek()` sit next to `buildWeekDomain()`.** `buildWeekDomain()` only lists Mondays. Filling per `kind`, snapping off-Monday keys and merging split weeks are separate, testable steps. + +--- + +### Task 1: Palette and tokens (gate) + +Every later task depends on this one. Its values are produced here, not in the plan: the plan gives the starting hexes, the exact commands and the acceptance criteria. + +**Files:** +- Modify: `src/app/globals.css` (`:root` block, lines 6-32; bare `[data-theme-mode="light"]` block, lines 62-89) +- Modify: `tailwind.config.ts` +- Modify: `docs/superpowers/specs/2026-09-25-glook-58-recharts-migration-design.md` (the `### Palette` section only) +- Create: `src/lib/__tests__/unit/chart-tokens-css.test.ts` (spec guard test 2) +- Create: `src/lib/__tests__/unit/chart-contrast.test.ts` (spec guard test 3) + +**Interfaces:** +- Consumes: `THEMES` from `src/app/themes.ts`. +- Produces: + - **CSS custom properties** in both blocks: + - `--chart-grid`, `--chart-axis`, `--chart-cursor`, `--chart-tooltip-bg`, `--chart-tooltip-border`, `--chart-tooltip-text`, `--chart-track`, `--chart-surface` + - `--chart-type-feature`, `--chart-type-bug`, `--chart-type-refactor`, `--chart-type-infra`, `--chart-type-docs`, `--chart-type-test`, `--chart-type-other`, `--chart-type-in-flight` + - `--chart-lines-added`, `--chart-lines-removed` + - `--chart-ring-jira`, `--chart-ring-commits` + - `--chart-volume-prs`, `--chart-volume-jiras`, `--chart-volume-commits` + - **Tailwind classes:** + - Chrome: `bg-/text-/fill-/stroke-/border-` + `chart-grid`, `chart-axis`, `chart-cursor`, `chart-tooltip-bg`, `chart-tooltip-border`, `chart-tooltip-text`, `chart-track`, `chart-surface` + - Types: `bg-chart-type-feature` … `bg-chart-type-in-flight` + +**Why `--chart-volume-*` exists.** It is not in the spec's token list. The spec does say the `ProjectsCard` volume bar's colors "move to tokens" (Non-goals), and that bar uses three colors no other token covers: cyan PRs, purple Jiras and a faint white "commits" remainder. The white remainder is invisible on a light card today. `prs` and `jiras` get the same 3:1 contrast gate as the other fixed palettes. `commits` is a deliberately de-emphasized remainder, so it is exempt from the gate, and it only has to differ from `--chart-track`. + +- [ ] **Step 1: Write guard test 2 (tokens in both modes)** + +Create `src/lib/__tests__/unit/chart-tokens-css.test.ts`: + +```ts +// src/lib/__tests__/unit/chart-tokens-css.test.ts +// GLOOK-58 guard test 2: every --chart-* token is defined for dark (:root) AND light (the bare +// [data-theme-mode="light"] block), as a 6-digit hex, and every var(--chart-*) that chart code or +// the Tailwind config references actually exists. A token missing from the light block would fall +// back to the dark value and put dark internals inside a light card, the bug this ticket fixes. +import fs from 'fs'; +import path from 'path'; + +const root = path.join(__dirname, '../../../..'); +const css = fs.readFileSync(path.join(root, 'src/app/globals.css'), 'utf8'); + +function extractBlock(source: string, selectorLine: RegExp): string { + const lines = source.split('\n'); + const start = lines.findIndex(l => selectorLine.test(l.trim())); + if (start === -1) throw new Error(`selector not found: ${selectorLine}`); + let depth = 0; + const out: string[] = []; + for (let i = start; i < lines.length; i++) { + out.push(lines[i]); + depth += (lines[i].match(/{/g) || []).length; + depth -= (lines[i].match(/}/g) || []).length; + if (i > start && depth <= 0) break; + } + return out.join('\n'); +} + +function chartTokens(block: string): Record { + const out: Record = {}; + const re = /--(chart-[a-z0-9-]+):\s*([^;]+);/g; + let m: RegExpExecArray | null; + while ((m = re.exec(block))) out[m[1]] = m[2].trim(); + return out; +} + +const dark = chartTokens(extractBlock(css, /^:root\s*{$/)); +// Match the bare selector exactly, not one of the many `[data-theme-mode="light"] .foo {` rules. +const light = chartTokens(extractBlock(css, /^\[data-theme-mode="light"\]\s*{$/)); + +const REQUIRED = [ + 'chart-grid', 'chart-axis', 'chart-cursor', 'chart-tooltip-bg', 'chart-tooltip-border', + 'chart-tooltip-text', 'chart-track', 'chart-surface', + 'chart-type-feature', 'chart-type-bug', 'chart-type-refactor', 'chart-type-infra', + 'chart-type-docs', 'chart-type-test', 'chart-type-other', 'chart-type-in-flight', + 'chart-lines-added', 'chart-lines-removed', + 'chart-ring-jira', 'chart-ring-commits', + 'chart-volume-prs', 'chart-volume-jiras', 'chart-volume-commits', +]; + +// Files whose var(--chart-*) references must resolve. Missing files are skipped, so this test can +// land before the chart modules exist and tightens as they arrive. +const REFERENCING_FILES = [ + 'tailwind.config.ts', + 'src/app/vulnerabilities/trend-chart.tsx', + 'src/app/projects/progress-ring.tsx', + 'src/components/ProjectsCard.tsx', +]; +const CHART_DIR = path.join(root, 'src/components/charts'); + +function referencedTokens(): Array<{ file: string; token: string }> { + const files = [...REFERENCING_FILES.map(f => path.join(root, f))]; + if (fs.existsSync(CHART_DIR)) { + for (const f of fs.readdirSync(CHART_DIR)) files.push(path.join(CHART_DIR, f)); + } + const out: Array<{ file: string; token: string }> = []; + for (const file of files) { + if (!fs.existsSync(file)) continue; + const src = fs.readFileSync(file, 'utf8'); + for (const m of src.matchAll(/var\(--(chart-[a-z0-9-]+)\)/g)) out.push({ file: path.relative(root, file), token: m[1] }); + } + return out; +} + +it('defines every required --chart-* token under :root (dark)', () => { + expect(REQUIRED.filter(t => !(t in dark))).toEqual([]); +}); + +it('defines exactly the same --chart-* tokens under the bare light block as under :root', () => { + expect(Object.keys(light).sort()).toEqual(Object.keys(dark).sort()); +}); + +it('every --chart-* value in both modes is a 6-digit hex (the contrast guard only parses that form)', () => { + const bad = [ + ...Object.entries(dark).filter(([, v]) => !/^#[0-9a-fA-F]{6}$/.test(v)).map(([k, v]) => `dark --${k}: ${v}`), + ...Object.entries(light).filter(([, v]) => !/^#[0-9a-fA-F]{6}$/.test(v)).map(([k, v]) => `light --${k}: ${v}`), + ]; + expect(bad).toEqual([]); +}); + +it('every var(--chart-*) referenced by chart code or the Tailwind config is defined', () => { + const missing = referencedTokens().filter(r => !(r.token in dark)).map(r => `${r.file}: --${r.token}`); + expect(missing).toEqual([]); +}); +``` + +- [ ] **Step 2: Write guard test 3 (contrast)** + +Create `src/lib/__tests__/unit/chart-contrast.test.ts`: + +```ts +// src/lib/__tests__/unit/chart-contrast.test.ts +// GLOOK-58 guard test 3: every contrast threshold in spec Decision 14, in both modes. +// - Fixed palettes (commit types, lines, ring, volume prs/jiras) >= 3:1 against the card surface. +// - Ring colours >= 3:1 against --chart-track, the surface they are drawn over. +// - All ten theme accents >= 3:1 against their own mode's card surface (single-metric timelines +// are drawn in var(--accent)). +// - --chart-axis >= 4.5:1 against the card; --chart-tooltip-text >= 4.5:1 against --chart-tooltip-bg. +// - --chart-grid >= 3:1 against the card; --chart-tooltip-border >= 3:1 against --chart-tooltip-bg. +// Colour-blind separation is checked by the dataviz validator in the palette task and recorded in +// the spec's Palette section; it is not recomputed here. +import fs from 'fs'; +import path from 'path'; +import { THEMES } from '@/app/themes'; + +const css = fs.readFileSync(path.join(__dirname, '../../../app/globals.css'), 'utf8'); + +function extractBlock(source: string, selectorLine: RegExp): string { + const lines = source.split('\n'); + const start = lines.findIndex(l => selectorLine.test(l.trim())); + if (start === -1) throw new Error(`selector not found: ${selectorLine}`); + let depth = 0; + const out: string[] = []; + for (let i = start; i < lines.length; i++) { + out.push(lines[i]); + depth += (lines[i].match(/{/g) || []).length; + depth -= (lines[i].match(/}/g) || []).length; + if (i > start && depth <= 0) break; + } + return out.join('\n'); +} + +function parseHexVars(block: string): Record { + const out: Record = {}; + const re = /--(chart-[a-z0-9-]+):\s*(#[0-9a-fA-F]{6})\s*;/g; + let m: RegExpExecArray | null; + while ((m = re.exec(block))) out[m[1]] = m[2].toLowerCase(); + return out; +} + +function hexToRgb(hex: string): [number, number, number] { + const n = parseInt(hex.slice(1), 16); + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; +} +// WCAG relative luminance: https://www.w3.org/TR/WCAG21/#dfn-relative-luminance +function channel(c: number): number { + const v = c / 255; + return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); +} +function luminance([r, g, b]: [number, number, number]): number { + return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b); +} +function contrast(a: string, b: string): number { + const [hi, lo] = [luminance(hexToRgb(a)), luminance(hexToRgb(b))].sort((x, y) => y - x); + return (hi + 0.05) / (lo + 0.05); +} + +const modes = { + dark: parseHexVars(extractBlock(css, /^:root\s*{$/)), + light: parseHexVars(extractBlock(css, /^\[data-theme-mode="light"\]\s*{$/)), +} as const; + +const FIXED = [ + 'chart-type-feature', 'chart-type-bug', 'chart-type-refactor', 'chart-type-infra', + 'chart-type-docs', 'chart-type-test', 'chart-type-other', 'chart-type-in-flight', + 'chart-lines-added', 'chart-lines-removed', + 'chart-ring-jira', 'chart-ring-commits', + 'chart-volume-prs', 'chart-volume-jiras', +]; + +function check(pairs: Array<[string, string, string, number]>): string[] { + return pairs + .filter(([, a, b, min]) => contrast(a, b) < min) + .map(([label, a, b, min]) => `${label}: ${a} vs ${b} = ${contrast(a, b).toFixed(2)} (< ${min})`); +} + +describe.each(['dark', 'light'] as const)('%s mode chart contrast', mode => { + const v = modes[mode]; + + it('--chart-surface is the card colour charts sit on (dark bg-gray-900 #111827, light card #ffffff)', () => { + expect(v['chart-surface']).toBe(mode === 'dark' ? '#111827' : '#ffffff'); + }); + + it('fixed palettes clear 3:1 against the card surface', () => { + expect(check(FIXED.map(t => [`--${t}`, v[t], v['chart-surface'], 3] as [string, string, string, number]))).toEqual([]); + }); + + it('ring colours clear 3:1 against --chart-track', () => { + expect(check([ + ['--chart-ring-jira vs track', v['chart-ring-jira'], v['chart-track'], 3], + ['--chart-ring-commits vs track', v['chart-ring-commits'], v['chart-track'], 3], + ])).toEqual([]); + }); + + it('every theme accent in this mode clears 3:1 against the card surface', () => { + const accents = THEMES.filter(t => t.mode === mode); + expect(accents.length).toBeGreaterThan(0); + expect(check(accents.map(t => [`${t.id} accent`, t.accent.toLowerCase(), v['chart-surface'], 3] as [string, string, string, number]))).toEqual([]); + }); + + it('chrome text clears 4.5:1 and chrome lines clear 3:1 against their own backgrounds', () => { + expect(check([ + ['--chart-axis vs surface', v['chart-axis'], v['chart-surface'], 4.5], + ['--chart-tooltip-text vs tooltip-bg', v['chart-tooltip-text'], v['chart-tooltip-bg'], 4.5], + ['--chart-grid vs surface', v['chart-grid'], v['chart-surface'], 3], + ['--chart-tooltip-border vs tooltip-bg', v['chart-tooltip-border'], v['chart-tooltip-bg'], 3], + ])).toEqual([]); + }); +}); +``` + +- [ ] **Step 3: Run both guards and confirm they fail** + +Run: `env PATH="/opt/homebrew/Cellar/node@24/24.16.0/bin:/usr/bin:/bin:/usr/sbin:/sbin" sh -c 'npx jest src/lib/__tests__/unit/chart-tokens-css.test.ts src/lib/__tests__/unit/chart-contrast.test.ts'` + +Expected: FAIL. The tokens test lists all 23 required tokens as missing. The contrast test fails with `undefined`-valued comparisons, or with the `--chart-surface` assertion. + +- [ ] **Step 4: Run the dataviz validator on the starting palettes** + +The validator lives at `/private/tmp/claude-501/bundled-skills/2.1.282/27f946b5296782145e05ad642766e802/dataviz/scripts/validate_palette.js`. If that path is gone, find it with `ls /private/tmp/claude-501/bundled-skills` and use the `dataviz/scripts/validate_palette.js` under the newest version directory. + +Starting values: + +| Palette (in stack/adjacency order) | Dark (surface `#111827`) | Light (surface `#ffffff`) | +|---|---|---| +| Commit types (feature, bug, refactor, infra, docs, test, other, in_flight) | `#3B82F6,#EF4444,#A855F7,#EAB308,#6B7280,#22C55E,#9CA3AF,#06B6D4` | `#2563EB,#DC2626,#9333EA,#A16207,#6B7280,#15803D,#4B5563,#0E7490` | +| Lines (added, in-flight, removed) | `#10B981,#06B6D4,#EF4444` | `#047857,#0E7490,#DC2626` | +| Ring (jira, commits) | `#D97706,#10B981` | `#B45309,#047857` | +| Volume (prs, jiras) | `#06B6D4,#A855F7` | `#0E7490,#9333EA` | + +The dark commit-type starting set is today's `TYPE_HEX` (`org/page.tsx:22-31`) with one change: `other` moves from `#4B5563` to `#9CA3AF`, because `#4B5563` measures 2.35:1 on `#111827`. The light set starts from the nearest Tailwind 600/700 steps. The lines palette includes the in-flight color, because the in-flight hatch sits against both added and removed. + +Run each command, one per invocation. Commit types, dark, adjacent pairs: + +`env PATH="/opt/homebrew/Cellar/node@24/24.16.0/bin:/usr/bin:/bin:/usr/sbin:/sbin" sh -c 'node /private/tmp/claude-501/bundled-skills/2.1.282/27f946b5296782145e05ad642766e802/dataviz/scripts/validate_palette.js "#3B82F6,#EF4444,#A855F7,#EAB308,#6B7280,#22C55E,#9CA3AF,#06B6D4" --mode dark --surface "#111827"'` + +Commit types, dark, all pairs (the donut sorts by count, so any two types can end up adjacent; record this, do not gate on it): + +`env PATH="/opt/homebrew/Cellar/node@24/24.16.0/bin:/usr/bin:/bin:/usr/sbin:/sbin" sh -c 'node /private/tmp/claude-501/bundled-skills/2.1.282/27f946b5296782145e05ad642766e802/dataviz/scripts/validate_palette.js "#3B82F6,#EF4444,#A855F7,#EAB308,#6B7280,#22C55E,#9CA3AF,#06B6D4" --mode dark --surface "#111827" --pairs all'` + +Commit types, light, adjacent, then all pairs: + +`env PATH="/opt/homebrew/Cellar/node@24/24.16.0/bin:/usr/bin:/bin:/usr/sbin:/sbin" sh -c 'node /private/tmp/claude-501/bundled-skills/2.1.282/27f946b5296782145e05ad642766e802/dataviz/scripts/validate_palette.js "#2563EB,#DC2626,#9333EA,#A16207,#6B7280,#15803D,#4B5563,#0E7490" --mode light --surface "#ffffff"'` + +`env PATH="/opt/homebrew/Cellar/node@24/24.16.0/bin:/usr/bin:/bin:/usr/sbin:/sbin" sh -c 'node /private/tmp/claude-501/bundled-skills/2.1.282/27f946b5296782145e05ad642766e802/dataviz/scripts/validate_palette.js "#2563EB,#DC2626,#9333EA,#A16207,#6B7280,#15803D,#4B5563,#0E7490" --mode light --surface "#ffffff" --pairs all'` + +Lines, both modes (all pairs, since each of the three touches the other two): + +`env PATH="/opt/homebrew/Cellar/node@24/24.16.0/bin:/usr/bin:/bin:/usr/sbin:/sbin" sh -c 'node /private/tmp/claude-501/bundled-skills/2.1.282/27f946b5296782145e05ad642766e802/dataviz/scripts/validate_palette.js "#10B981,#06B6D4,#EF4444" --mode dark --surface "#111827" --pairs all'` + +`env PATH="/opt/homebrew/Cellar/node@24/24.16.0/bin:/usr/bin:/bin:/usr/sbin:/sbin" sh -c 'node /private/tmp/claude-501/bundled-skills/2.1.282/27f946b5296782145e05ad642766e802/dataviz/scripts/validate_palette.js "#047857,#0E7490,#DC2626" --mode light --surface "#ffffff" --pairs all'` + +Ring, both modes: + +`env PATH="/opt/homebrew/Cellar/node@24/24.16.0/bin:/usr/bin:/bin:/usr/sbin:/sbin" sh -c 'node /private/tmp/claude-501/bundled-skills/2.1.282/27f946b5296782145e05ad642766e802/dataviz/scripts/validate_palette.js "#D97706,#10B981" --mode dark --surface "#111827"'` + +`env PATH="/opt/homebrew/Cellar/node@24/24.16.0/bin:/usr/bin:/bin:/usr/sbin:/sbin" sh -c 'node /private/tmp/claude-501/bundled-skills/2.1.282/27f946b5296782145e05ad642766e802/dataviz/scripts/validate_palette.js "#B45309,#047857" --mode light --surface "#ffffff"'` + +Volume, both modes: + +`env PATH="/opt/homebrew/Cellar/node@24/24.16.0/bin:/usr/bin:/bin:/usr/sbin:/sbin" sh -c 'node /private/tmp/claude-501/bundled-skills/2.1.282/27f946b5296782145e05ad642766e802/dataviz/scripts/validate_palette.js "#06B6D4,#A855F7" --mode dark --surface "#111827"'` + +`env PATH="/opt/homebrew/Cellar/node@24/24.16.0/bin:/usr/bin:/bin:/usr/sbin:/sbin" sh -c 'node /private/tmp/claude-501/bundled-skills/2.1.282/27f946b5296782145e05ad642766e802/dataviz/scripts/validate_palette.js "#0E7490,#9333EA" --mode light --surface "#ffffff"'` + +**Acceptance: gate on exactly the three checks spec Decision 14 names.** In each **adjacent** run (the default, no `--pairs all`): + +1. **`CVD separation`** must read `[PASS]`. That means ΔE ≥ 8. The validator's `[WARN]` band (6-8) counts as a failure here, because the spec's floor is 8. +2. **`Normal-vision floor`** must read `[PASS]`. +3. **3:1 contrast is gated by `chart-contrast.test.ts`,** not by the validator's `Contrast vs surface` line. Record that line, but the Jest test is authoritative. + +`Lightness band` and `Chroma floor` are **recorded, not gated.** `docs` and `other` are grays by design, so the chroma floor always fails them. The validator's exit code is therefore expected to be 1 for the commit-type palettes, so don't loop on it. The `--pairs all` runs are recorded, not gated. + +**Snapping rule when a gated check fails:** +- Move the failing color one Tailwind shade within its hue family. Dark mode goes lighter (500→400), light mode goes darker (600→700). +- If the pair is still too close, change the other member of the pair instead. +- Keep feature blue and bug red in both modes (Decision 8). +- Re-run until the gated checks pass. + +Save each final command's full output. It goes into the spec in Step 7. + +- [ ] **Step 5: Add the tokens to `globals.css`** + +Add this at the end of the `:root` block, after `--vuln-series-other` (line 31, before the closing `}` on line 32). Replace any value Step 4 changed. + +```css + /* GLOOK-58: chart tokens (dark, the app's default). Values validated in the spec's Palette + section; chart-contrast.test.ts guards every Decision 14 threshold. --chart-surface is the + card colour charts sit on (bg-gray-900). */ + --chart-grid: #6b7280; + --chart-axis: #9ca3af; + --chart-cursor: #1f2937; + --chart-tooltip-bg: #1f2937; + --chart-tooltip-border: #6b7280; + --chart-tooltip-text: #e5e7eb; + --chart-track: #1f2937; + --chart-surface: #111827; + --chart-type-feature: #3b82f6; + --chart-type-bug: #ef4444; + --chart-type-refactor: #a855f7; + --chart-type-infra: #eab308; + --chart-type-docs: #6b7280; + --chart-type-test: #22c55e; + --chart-type-other: #9ca3af; + --chart-type-in-flight: #06b6d4; + --chart-lines-added: #10b981; + --chart-lines-removed: #ef4444; + --chart-ring-jira: #d97706; + --chart-ring-commits: #10b981; + --chart-volume-prs: #06b6d4; + --chart-volume-jiras: #a855f7; + --chart-volume-commits: #374151; +``` + +Add this at the end of the bare `[data-theme-mode="light"]` block, after `--vuln-series-other: #6b7280;` (line 88, before the closing `}` on line 89). Again, replace any value Step 4 changed. + +```css + /* GLOOK-58: chart tokens (light). Same names as :root. --chart-surface is the light card + (the .bg-gray-900 override below is #ffffff). */ + --chart-grid: #6b7280; + --chart-axis: #4b5563; + --chart-cursor: #f3f4f6; + --chart-tooltip-bg: #ffffff; + --chart-tooltip-border: #6b7280; + --chart-tooltip-text: #111827; + --chart-track: #e5e7eb; + --chart-surface: #ffffff; + --chart-type-feature: #2563eb; + --chart-type-bug: #dc2626; + --chart-type-refactor: #9333ea; + --chart-type-infra: #a16207; + --chart-type-docs: #6b7280; + --chart-type-test: #15803d; + --chart-type-other: #4b5563; + --chart-type-in-flight: #0e7490; + --chart-lines-added: #047857; + --chart-lines-removed: #dc2626; + --chart-ring-jira: #b45309; + --chart-ring-commits: #047857; + --chart-volume-prs: #0e7490; + --chart-volume-jiras: #9333ea; + --chart-volume-commits: #d1d5db; +``` + +- [ ] **Step 6: Register the `chart.*` color namespace in Tailwind** + +Replace the whole of `tailwind.config.ts` with: + +```ts +import type { Config } from 'tailwindcss'; + +// GLOOK-58: chart tokens are CSS variables (globals.css), so the same class resolves to the dark +// value under :root and the light value under [data-theme-mode="light"]. Opacity modifiers +// (e.g. bg-chart-grid/50) do NOT work on these: Tailwind v3 cannot split a var() hex into channels. +const config: Config = { + content: ['./src/**/*.{js,ts,jsx,tsx,mdx}'], + theme: { + extend: { + colors: { + chart: { + grid: 'var(--chart-grid)', + axis: 'var(--chart-axis)', + cursor: 'var(--chart-cursor)', + 'tooltip-bg': 'var(--chart-tooltip-bg)', + 'tooltip-border': 'var(--chart-tooltip-border)', + 'tooltip-text': 'var(--chart-tooltip-text)', + track: 'var(--chart-track)', + surface: 'var(--chart-surface)', + type: { + feature: 'var(--chart-type-feature)', + bug: 'var(--chart-type-bug)', + refactor: 'var(--chart-type-refactor)', + infra: 'var(--chart-type-infra)', + docs: 'var(--chart-type-docs)', + test: 'var(--chart-type-test)', + other: 'var(--chart-type-other)', + 'in-flight': 'var(--chart-type-in-flight)', + }, + }, + }, + }, + }, + plugins: [], +}; + +export default config; +``` + +- [ ] **Step 7: Run both guards and confirm they pass** + +Run: `env PATH="/opt/homebrew/Cellar/node@24/24.16.0/bin:/usr/bin:/bin:/usr/sbin:/sbin" sh -c 'npx jest src/lib/__tests__/unit/chart-tokens-css.test.ts src/lib/__tests__/unit/chart-contrast.test.ts'` + +Expected: PASS. If a contrast line fails, snap that token as in Step 4 (for chrome grays, step one Tailwind gray at a time) and re-run. Re-run the matching validator command for any fixed-palette color you changed. + +- [ ] **Step 8: Write the Palette section into the spec** + +In the spec, replace the placeholder paragraph under `### Palette` (the one beginning *"Filled in by the first implementation task"*) with: + +1. **A table.** Columns: token, dark hex, light hex, and what it colors. One row per `--chart-*` token. +2. **A short list of every value that moved from its starting hex,** with the reason, for example "`--chart-type-other` dark `#4B5563` → `#9CA3AF`: 2.35:1 on `#111827`". +3. **The full validator output for every run in Step 4,** in fenced code blocks, labeled by palette, mode and pairs. Adjacent runs are the gated ones. `--pairs all` runs are "recorded, not gated". +4. **One sentence on what is not gated:** lightness band, chroma floor (grays fail it by design), and all-pairs separation. + +- [ ] **Step 9: Run the full suite** + +Run: `env PATH="/opt/homebrew/Cellar/node@24/24.16.0/bin:/usr/bin:/bin:/usr/sbin:/sbin" sh -c 'npx jest --maxWorkers=3'` + +Expected: all suites pass. That is 171 suites: the 169 baseline plus the 2 new ones. `vuln-trend-colors-css.test.ts` and `vuln-series-contrast.test.ts` still pass, because `--vuln-series-*` is untouched. + +- [ ] **Step 10: Commit** + +```bash +git add src/app/globals.css tailwind.config.ts src/lib/__tests__/unit/chart-tokens-css.test.ts src/lib/__tests__/unit/chart-contrast.test.ts docs/superpowers/specs/2026-09-25-glook-58-recharts-migration-design.md +git commit -F - <<'EOF' +GLOOK-58: chart palette tokens and contrast guards + +Co-Authored-By: Claude Opus 5.5 +EOF +``` + +--- + +### Task 2: Foundation (dependencies, jsdom setup, `cn`, ported `chart.tsx`) + +**Files:** +- Modify: `package.json`, `package-lock.json` (via npm) +- Modify: `jest.config.ts` +- Create: `src/lib/cn.ts` +- Create: `src/lib/__tests__/setup/resize-observer.ts` +- Create: `src/lib/__tests__/setup/chart-size.ts` +- Create: `src/components/charts/chart.tsx` +- Test: `src/lib/__tests__/unit/cn.test.ts`, `src/lib/__tests__/unit/chart-wrapper.test.tsx` + +**Interfaces:** +- Consumes: `chart.*` Tailwind colors from Task 1. +- Produces: + - `cn(...inputs: ClassValue[]): string` from `@/lib/cn` + - `fixChartSize(width?: number, height?: number): void` from `src/lib/__tests__/setup/chart-size.ts`. It registers a `beforeEach`. + - From `@/components/charts/chart`: + - `ChartContainer` (props: `config: ChartConfig`, `className?`, `initialDimension?`, `children`) + - `ChartTooltip` (alias of Recharts `Tooltip`) + - `ChartTooltipContent`, `ChartLegend`, `ChartLegendContent`, `ChartStyle` + - `type ChartConfig` + - `CHART_TOOLTIP_CLASS: string` + +**Why `react-is@^19.2` is added.** It is not in the spec's dependency list. Recharts 3.10.1 has `react-is` as a peer dependency and imports `isFragment` from it (`es6/util/ReactUtils.js:3`). The copy hoisted in `node_modules` today is 18.3.1, while React is 19.2.4. React 19 changed the element `$$typeof` symbol, so `react-is` 18 does not recognize React 19 fragments. Recharts would then fail to flatten `<>…` children. + +**Two jsdom facts this task works around.** Both were read from the Recharts 3.10.1 source. + +1. **`initialDimension` does not survive a mount in jsdom.** `ResponsiveContainer`'s size detector (`es6/component/ResponsiveContainer.js:96-123`) returns early when `ResizeObserver` is undefined. When it is defined, it immediately calls `getBoundingClientRect()` and stores the result. In jsdom that result is 0×0, which overwrites `initialDimension` and renders no chart. The spec's Testing section suggests `initialDimension` as the sizing mechanism; it is not sufficient on its own. The global stub makes `ResizeObserver` exist, and `fixChartSize()` stubs `getBoundingClientRect` so the measurement returns a real size. +2. **Recharts animation.** This is handled by the Global Constraints rule: every series sets `isAnimationActive={false}`. + +**Wrapper port choices (spec Decisions 3, 4, 6).** +- **Tailwind classes** come from shadcn `new-york` (the Tailwind v3 variant), fetched from `https://ui.shadcn.com/r/styles/new-york/chart.json`. For example, it uses `border-[--color-border]`, not v4's `border-(--color-border)`. +- **TypeScript types** come from `new-york-v4`, which already targets Recharts 3.8: `DefaultTooltipContentProps`, `DefaultLegendContentProps` and the `initialDimension` prop. This is how the port applies the Recharts 3 type changes Decision 3 asks for. Components are plain functions (React 19 passes `ref` as a prop), as in v4. +- **`THEMES`** is `{ dark: '', light: '[data-theme-mode="light"]' }` (Decision 4). +- **Class rewrites (Decision 6):** + - `fill-muted-foreground` → `fill-chart-axis` + - `stroke-border/50` and `stroke-border` → `stroke-chart-grid`. Opacity modifiers are dropped, because Tailwind v3 cannot apply alpha to a `var()` hex, and 50% would break the grid's 3:1 guarantee. + - `fill-muted` → `fill-chart-track` (radial background) or `fill-chart-cursor` (tooltip cursor) + - the curve cursor → `stroke-chart-cursor` + - `border-border/50` → `border-chart-tooltip-border` + - `bg-background` → `bg-chart-tooltip-bg` + - `text-muted-foreground` and `text-foreground` → `text-chart-tooltip-text` + - The legend root gains `text-chart-axis`, so legend labels are chrome text. +- **The `[stroke='#ccc']` and `[stroke='#fff']` attribute selectors stay.** They match the default colors Recharts emits and restyle them. They never set a color. The Task 10 guard strips attribute selectors before it scans, and proves that a bare hex still fails. +- **The value display uses v4's `item.value != null`,** so a 0 is shown. The v3 variant's `item.value &&` hides zeros. +- **`chartId` is sanitized with `/[^A-Za-z0-9_-]/g`,** not just `:`. React 19's `useId()` format is not guaranteed to be `:r0:`, and the ID goes unquoted into a CSS attribute selector. + +- [ ] **Step 1: Install the dependencies** + +Run: `env PATH="/opt/homebrew/Cellar/node@24/24.16.0/bin:/usr/bin:/bin:/usr/sbin:/sbin" sh -c 'npm install recharts@^3.10 clsx@^2.1 tailwind-merge@^2.6 react-is@^19.2'` + +Expected: `package.json` `dependencies` gains `"recharts": "^3.10.1"` (or later 3.x), `"clsx": "^2.1.x"`, `"tailwind-merge": "^2.6.x"` and `"react-is": "^19.2.x"`. + +- [ ] **Step 2: Confirm Recharts resolves react-is 19** + +Run: `env PATH="/opt/homebrew/Cellar/node@24/24.16.0/bin:/usr/bin:/bin:/usr/sbin:/sbin" sh -c 'npm ls react-is'` + +Expected: the `recharts@3.x` entry shows `react-is@19.x` (deduped is fine). If it shows 18.x, stop and report it. Do not continue with a mismatched `react-is`. + +- [ ] **Step 3: Add the jsdom setup file and the Jest `setupFiles` entry** + +Create `src/lib/__tests__/setup/resize-observer.ts`: + +```ts +// GLOOK-58: jsdom has no ResizeObserver. Recharts' ResponsiveContainer skips its size detector +// entirely without one, so any test that renders a chart (including the vuln-content-* tests that +// render VulnerabilitiesContent with the real TrendChart) needs this stub. Guarded so node-env +// suites are untouched. The stub never fires: sizing in chart tests comes from fixChartSize(). +if (typeof window !== 'undefined' && typeof (globalThis as { ResizeObserver?: unknown }).ResizeObserver === 'undefined') { + class ResizeObserverStub { + observe(): void {} + unobserve(): void {} + disconnect(): void {} + } + (globalThis as { ResizeObserver?: unknown }).ResizeObserver = ResizeObserverStub; +} + +export {}; +``` + +In `jest.config.ts`, add a `setupFiles` line directly after `roots: ['/src/lib'],`: + +```ts + setupFiles: ['/src/lib/__tests__/setup/resize-observer.ts'], +``` + +- [ ] **Step 4: Add the chart-size test helper** + +Create `src/lib/__tests__/setup/chart-size.ts`: + +```ts +// GLOOK-58: Recharts' ResponsiveContainer measures its div with getBoundingClientRect() on mount +// and overwrites initialDimension with the result. jsdom returns 0x0, so without this every chart +// test would render an empty container. Call once at the top of a chart test file. It re-applies +// in beforeEach because jest.config.ts has restoreMocks: true, which undoes spies after each test. +export function fixChartSize(width = 640, height = 240): void { + beforeEach(() => { + jest.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation( + () => ({ x: 0, y: 0, top: 0, left: 0, right: width, bottom: height, width, height, toJSON: () => ({}) }) as DOMRect, + ); + }); +} +``` + +- [ ] **Step 5: Write the failing tests** + +Create `src/lib/__tests__/unit/cn.test.ts`: + +```ts +import { cn } from '@/lib/cn'; + +it('joins truthy classes and drops falsy ones', () => { + expect(cn('a', false && 'b', undefined, 'c')).toBe('a c'); +}); + +it('lets a later conflicting utility win', () => { + expect(cn('px-2', 'px-4')).toBe('px-4'); +}); + +it('keeps a font-size and a chart colour together (different groups, both survive)', () => { + expect(cn('text-xs', 'text-chart-axis')).toBe('text-xs text-chart-axis'); +}); +``` + +Create `src/lib/__tests__/unit/chart-wrapper.test.tsx`: + +```tsx +/** @jest-environment jsdom */ +// GLOOK-58 Task 2 gate: Recharts 3 renders real marks in jsdom through the ported wrapper, and the +// wrapper's theme selectors and chrome classes follow Decisions 4 and 6. +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { Bar, BarChart } from 'recharts'; +import { + ChartContainer, ChartLegendContent, ChartStyle, ChartTooltipContent, type ChartConfig, +} from '@/components/charts/chart'; +import { fixChartSize } from '../setup/chart-size'; + +fixChartSize(); + +const config: ChartConfig = { commits: { label: 'Commits', color: 'var(--accent)' } }; + +it('renders one bar rectangle per non-zero datum (spike: Recharts draws in jsdom)', () => { + const { container } = render( + + + + + , + ); + const rects = container.querySelectorAll('.recharts-bar-rectangle path.recharts-rectangle'); + expect(rects).toHaveLength(2); + rects.forEach(r => expect(r.getAttribute('fill')).toBe('var(--color-commits)')); +}); + +it('ChartStyle scopes dark to the unprefixed selector and light to [data-theme-mode="light"]', () => { + const { container } = render( + , + ); + const css = container.querySelector('style')!.innerHTML; + expect(css).toMatch(/(^|\n)\s*\[data-chart=chart-x\] \{\s*--color-a: var\(--dark-a\);/); + expect(css).toContain('[data-theme-mode="light"] [data-chart=chart-x] {\n --color-a: var(--light-a);'); + expect(css).not.toContain('.dark'); +}); + +it('the tooltip shows the configured label and a zero value, on chrome tokens', () => { + const payload = [{ name: 'commits', dataKey: 'commits', value: 0, color: 'var(--accent)', payload: {} }]; + const { container } = render( + + ['payload']} label="2026-09-21" hideLabel /> + , + ); + expect(screen.getByText('Commits')).toBeTruthy(); + expect(screen.getByText('0')).toBeTruthy(); + const root = container.querySelector('.bg-chart-tooltip-bg'); + expect(root).not.toBeNull(); + expect(root!.className).toContain('border-chart-tooltip-border'); + expect(root!.className).toContain('text-chart-tooltip-text'); +}); + +it('the legend lists configured labels in chrome text', () => { + const payload = [{ value: 'commits', dataKey: 'commits', color: 'var(--accent)', type: 'square' as const }]; + const { container } = render( + + + , + ); + expect(screen.getByText('Commits')).toBeTruthy(); + expect(container.querySelector('.text-chart-axis')).not.toBeNull(); +}); +``` + +- [ ] **Step 6: Run the tests and confirm they fail** + +Run: `env PATH="/opt/homebrew/Cellar/node@24/24.16.0/bin:/usr/bin:/bin:/usr/sbin:/sbin" sh -c 'npx jest src/lib/__tests__/unit/cn.test.ts src/lib/__tests__/unit/chart-wrapper.test.tsx'` + +Expected: FAIL with `Cannot find module '@/lib/cn'` and `Cannot find module '@/components/charts/chart'`. + +- [ ] **Step 7: Implement `cn`** + +Create `src/lib/cn.ts`: + +```ts +import { clsx, type ClassValue } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +/** Join class names and resolve Tailwind conflicts (later wins). Used by the chart wrapper. */ +export function cn(...inputs: ClassValue[]): string { + return twMerge(clsx(inputs)); +} +``` + +- [ ] **Step 8: Implement the ported wrapper** + +Create `src/components/charts/chart.tsx`: + +```tsx +'use client'; + +// Ported from shadcn/ui's Tailwind-v3 "new-york" chart.tsx (GLOOK-58 Decision 3), with the +// Recharts 3 types from the "new-york-v4" variant, the theme map inverted for this app +// (Decision 4), and shadcn's generic colour classes rewritten to the chart.* namespace (Decision 6). + +import * as React from 'react'; +import * as RechartsPrimitive from 'recharts'; +import type { TooltipValueType } from 'recharts'; +import { cn } from '@/lib/cn'; + +// Format: { THEME_NAME: CSS_SELECTOR }. The app's unscoped styles are dark and light is the +// override (applyTheme() in src/app/themes.ts sets data-theme-mode on ), so dark takes the +// empty selector. Renaming shadcn's `.dark` key in place would invert every chart. +const THEMES = { dark: '', light: '[data-theme-mode="light"]' } as const; + +const INITIAL_DIMENSION = { width: 320, height: 200 } as const; +type TooltipNameType = number | string; + +export type ChartConfig = Record< + string, + { + label?: React.ReactNode; + icon?: React.ComponentType; + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record } + ) +>; + +/** Shared by ChartTooltipContent and the charts' own tooltip bodies. */ +export const CHART_TOOLTIP_CLASS = + 'grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-chart-tooltip-border bg-chart-tooltip-bg px-2.5 py-1.5 text-xs text-chart-tooltip-text shadow-xl'; + +type ChartContextProps = { config: ChartConfig }; + +const ChartContext = React.createContext(null); + +function useChart() { + const context = React.useContext(ChartContext); + if (!context) { + throw new Error('useChart must be used within a '); + } + return context; +} + +function ChartContainer({ + id, + className, + children, + config, + initialDimension = INITIAL_DIMENSION, + ...props +}: React.ComponentProps<'div'> & { + config: ChartConfig; + children: React.ComponentProps['children']; + initialDimension?: { width: number; height: number }; +}) { + const uniqueId = React.useId(); + const chartId = `chart-${(id ?? uniqueId).replace(/[^A-Za-z0-9_-]/g, '')}`; + + return ( + +
+ + + {children} + +
+
+ ); +} + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter(([, itemConfig]) => itemConfig.theme || itemConfig.color); + + if (!colorConfig.length) { + return null; + } + + return ( + '} + config={{ + evil: { color: 'red;}' }, + 'k}': { color: 'var(--chart-type-bug)' }, + bug: { color: 'var(--chart-type-bug)' }, + }} + />, + ); + const styles = container.querySelectorAll('style'); + expect(styles).toHaveLength(1); + expect(container.querySelector('script')).toBeNull(); + const css = styles[0].innerHTML; + expect(css).not.toContain('<'); + expect(css).not.toContain(''); + expect(css).not.toContain('--color-evil'); + // Only the two theme blocks close; no injected brace survives. + expect(css.match(/\}/g)).toHaveLength(2); + expect(css).toContain('[data-chart=xstylescriptyscript] {'); + expect(css).toContain(' --color-kstyleb: var(--chart-type-bug);'); + expect(css).toContain(' --color-bug: var(--chart-type-bug);'); +}); + it('the tooltip shows the configured label and a zero value, on chrome tokens', () => { const payload = [{ name: 'commits', dataKey: 'commits', value: 0, color: 'var(--accent)', payload: {}, graphicalItemId: 'bar-commits' }]; const { container } = render( From 88018432bdd4d6e055b9c558d04f9911d4d7bf8c Mon Sep 17 00:00:00 2001 From: Evgeniy Nepeipivo Date: Fri, 25 Sep 2026 13:15:20 -0400 Subject: [PATCH 13/53] GLOOK-58: UTC week keys, shared chart helpers, commit-type map and hatch pattern Co-Authored-By: Claude Sonnet 5 --- src/components/charts/chart-format.ts | 106 ++++++++++++++++ src/components/charts/commit-types.ts | 82 +++++++++++++ src/components/charts/hatch.tsx | 39 ++++++ src/lib/__tests__/unit/chart-format.test.ts | 126 ++++++++++++++++++++ src/lib/__tests__/unit/chart-hatch.test.tsx | 40 +++++++ src/lib/__tests__/unit/commit-types.test.ts | 58 +++++++++ src/lib/__tests__/unit/week-key-utc.test.ts | 35 ++++++ src/lib/report/timeline.ts | 13 +- 8 files changed, 494 insertions(+), 5 deletions(-) create mode 100644 src/components/charts/chart-format.ts create mode 100644 src/components/charts/commit-types.ts create mode 100644 src/components/charts/hatch.tsx create mode 100644 src/lib/__tests__/unit/chart-format.test.ts create mode 100644 src/lib/__tests__/unit/chart-hatch.test.tsx create mode 100644 src/lib/__tests__/unit/commit-types.test.ts create mode 100644 src/lib/__tests__/unit/week-key-utc.test.ts diff --git a/src/components/charts/chart-format.ts b/src/components/charts/chart-format.ts new file mode 100644 index 00000000..a9509b67 --- /dev/null +++ b/src/components/charts/chart-format.ts @@ -0,0 +1,106 @@ +// GLOOK-58: pure helpers shared by every chart. Numbers from DECIMAL/REAL columns can arrive as +// strings, so every value goes through toNum(). Weeks are UTC calendar weeks, matching the +// server's weekKeyForDate(), so keys match exactly. + +export type MetricKind = 'count' | 'ratio'; + +const DAY_MS = 86_400_000; + +/** Number, or 0 for anything unusable. Charts never throw and never render NaN. */ +export function toNum(v: unknown): number { + if (typeof v === 'number') return Number.isFinite(v) ? v : 0; + if (typeof v === 'string' && v.trim() !== '') { + const n = Number(v); + return Number.isFinite(n) ? n : 0; + } + return 0; +} + +function isoOf(t: number): string { + return new Date(t).toISOString().slice(0, 10); +} + +function utcDay(iso: string): number | null { + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso); + if (!m) return null; + const t = Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])); + return isoOf(t) === iso ? t : null; // rejects 2026-13-01, 2026-02-30 +} + +/** Monday of the UTC week containing `date`, as YYYY-MM-DD. Same rule as weekKeyForDate(). */ +export function mondayOf(date: Date): string { + const t = Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()); + const dow = new Date(t).getUTCDay(); + return isoOf(t - ((dow + 6) % 7) * DAY_MS); +} + +/** Every Monday from the cutoff's week to today's week, inclusive. */ +export function buildWeekDomain(cutoff: Date, today: Date): string[] { + const start = utcDay(mondayOf(cutoff)) as number; + const end = utcDay(mondayOf(today)) as number; + const out: string[] = []; + for (let t = start; t <= end; t += 7 * DAY_MS) out.push(isoOf(t)); + return out; +} + +/** The page-wide domain: the last `days` days (default 90). Compute once per page and pass it to every chart. */ +export function recentWeekDomain(now: Date = new Date(), days = 90): string[] { + return buildWeekDomain(new Date(now.getTime() - days * DAY_MS), now); +} + +/** Rows keyed by their exact week. aggregateWeekly emits one row per key, so there are no collisions. */ +export function indexByWeek(data: T[]): Map { + return new Map(data.map(r => [r.week, r])); +} + +export interface WeekPoint { + week: string; + value: number | null; + hasData: boolean; + row?: T; +} + +export interface FillOptions { + value: (row: T) => unknown; + kind: MetricKind; + isDefined?: (row: T) => boolean; +} + +/** + * One point per domain week. kind 'count': a missing or undefined week is 0. + * kind 'ratio': a missing or undefined week is null (a gap). Rows outside the domain are ignored. + */ +export function fillWeeks(weeks: string[], data: T[], opts: FillOptions): WeekPoint[] { + const byWeek = indexByWeek(data); + return weeks.map(week => { + const row = byWeek.get(week); + const raw = row ? opts.value(row) : undefined; + const defined = !!row && raw != null && (opts.isDefined ? opts.isDefined(row) : true); + if (opts.kind === 'count') return { week, row, hasData: !!row, value: defined ? toNum(raw) : 0 }; + return { week, row, hasData: defined, value: defined ? toNum(raw) : null }; + }); +} + +export function formatWeek(iso: string): string { + const t = utcDay(iso); + if (t === null) return iso; + return new Date(t).toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' }); +} + +export function formatValue(v: number | null | undefined, { suffix = '', decimals = 0 }: { suffix?: string; decimals?: number } = {}): string { + if (v == null || !Number.isFinite(v)) return '—'; + const body = decimals > 0 ? v.toFixed(decimals) : Math.round(v).toLocaleString('en-US'); + return body + suffix; +} + +const COMPACT = new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 }); + +export function formatCompact(v: number): string { + return COMPACT.format(toNum(v)); +} + +/** True when every key after `key` in stacking order is zero in this row: that segment gets the rounded top. */ +export function isTopOfStack(row: Record, keys: readonly string[], key: string): boolean { + const i = keys.indexOf(key); + return keys.slice(i + 1).every(k => toNum(row[k]) === 0); +} diff --git a/src/components/charts/commit-types.ts b/src/components/charts/commit-types.ts new file mode 100644 index 00000000..756e0441 --- /dev/null +++ b/src/components/charts/commit-types.ts @@ -0,0 +1,82 @@ +// GLOOK-58: the one commit-type map. Replaces TYPE_HEX/TYPE_COLORS (org page), TYPE_COLORS/ +// TYPE_TEXT_COLORS (dev page) and TYPE_COLORS (team dev-table), so a palette change reaches every +// surface at once. Marks (bars, wedges, swatches) and badges (text on a fill) use separate tokens: +// a mark only needs 3:1 against the card, a badge's text needs 4.5:1 against its own fill. +// Class names are written out in full so Tailwind's content scan finds them. +import { toNum } from './chart-format'; + +export const COMMIT_TYPE_ORDER = ['feature', 'bug', 'refactor', 'infra', 'docs', 'test', 'other', 'in_flight'] as const; +export type CommitType = (typeof COMMIT_TYPE_ORDER)[number]; + +const COLOR: Record = { + feature: 'var(--chart-type-feature)', + bug: 'var(--chart-type-bug)', + refactor: 'var(--chart-type-refactor)', + infra: 'var(--chart-type-infra)', + docs: 'var(--chart-type-docs)', + test: 'var(--chart-type-test)', + other: 'var(--chart-type-other)', + in_flight: 'var(--chart-type-in-flight)', +}; + +const BG: Record = { + feature: 'bg-chart-type-feature', + bug: 'bg-chart-type-bug', + refactor: 'bg-chart-type-refactor', + infra: 'bg-chart-type-infra', + docs: 'bg-chart-type-docs', + test: 'bg-chart-type-test', + other: 'bg-chart-type-other', + in_flight: 'bg-chart-type-in-flight', +}; + +const BADGE: Record = { + feature: { bg: 'bg-chart-badge-feature-bg', text: 'text-chart-badge-feature-text' }, + bug: { bg: 'bg-chart-badge-bug-bg', text: 'text-chart-badge-bug-text' }, + refactor: { bg: 'bg-chart-badge-refactor-bg', text: 'text-chart-badge-refactor-text' }, + infra: { bg: 'bg-chart-badge-infra-bg', text: 'text-chart-badge-infra-text' }, + docs: { bg: 'bg-chart-badge-docs-bg', text: 'text-chart-badge-docs-text' }, + test: { bg: 'bg-chart-badge-test-bg', text: 'text-chart-badge-test-text' }, + other: { bg: 'bg-chart-badge-other-bg', text: 'text-chart-badge-other-text' }, + in_flight: { bg: 'bg-chart-badge-in-flight-bg', text: 'text-chart-badge-in-flight-text' }, +}; + +export function normalizeType(t: string): CommitType { + return (COMMIT_TYPE_ORDER as readonly string[]).includes(t) ? (t as CommitType) : 'other'; +} + +/** Mark colour (bars, wedges, lines, swatches). */ +export function commitTypeColor(t: string): string { + return COLOR[normalizeType(t)]; +} + +/** Mark colour as a background class, for HTML bar segments. */ +export function commitTypeBg(t: string): string { + return BG[normalizeType(t)]; +} + +/** Badge fill and text classes (text on a coloured fill, 4.5:1). */ +export function commitTypeBadge(t: string): { bg: string; text: string } { + return BADGE[normalizeType(t)]; +} + +/** Sum type counts across rows; unknown types count as other. */ +export function foldTypes(list: Array>): Record { + const out = Object.fromEntries(COMMIT_TYPE_ORDER.map(t => [t, 0])) as Record; + for (const types of list) { + for (const [t, n] of Object.entries(types ?? {})) out[normalizeType(t)] += toNum(n); + } + return out; +} + +/** + * Folded, non-zero [type, count] rows in fixed COMMIT_TYPE_ORDER (spec Decision 13). Never sorted by + * count: the palette gate validates neighbours in this order, so any other order could put two + * unchecked colours side by side (other and in_flight are always neighbours here). + */ +export function typeEntriesFrom(list: Array>): [CommitType, number][] { + const folded = foldTypes(list); + return COMMIT_TYPE_ORDER + .filter(t => folded[t] > 0) + .map(t => [t, folded[t]] as [CommitType, number]); +} diff --git a/src/components/charts/hatch.tsx b/src/components/charts/hatch.tsx new file mode 100644 index 00000000..951aa12e --- /dev/null +++ b/src/components/charts/hatch.tsx @@ -0,0 +1,39 @@ +'use client'; + +// GLOOK-58 Hatch: in-flight is never shown by colour alone. The pattern has an explicit +// --chart-surface background (never transparent) and ~2px diagonal stripes in the given colour. +// Each instance gets its own id from useId(): a page shows several charts in one document-wide +// id space, so a static id would make the accent and in-flight patterns collide. +import { useId, type ReactElement } from 'react'; + +const TILE = 6; + +export interface Hatch { + id: string; + fill: string; + defs: ReactElement; +} + +export function useHatch(colorVar: string): Hatch { + const id = `hatch-${useId().replace(/[^A-Za-z0-9_-]/g, '')}`; + const defs = ( + + + + + + + ); + return { id, fill: `url(#${id})`, defs }; +} + +/** A hatched legend swatch that carries its own pattern, so it works outside any chart's . */ +export function HatchSwatch({ colorVar, size = 10 }: { colorVar: string; size?: number }) { + const hatch = useHatch(colorVar); + return ( + + ); +} diff --git a/src/lib/__tests__/unit/chart-format.test.ts b/src/lib/__tests__/unit/chart-format.test.ts new file mode 100644 index 00000000..c0b47af8 --- /dev/null +++ b/src/lib/__tests__/unit/chart-format.test.ts @@ -0,0 +1,126 @@ +import { + buildWeekDomain, fillWeeks, formatCompact, formatValue, formatWeek, indexByWeek, isTopOfStack, + mondayOf, recentWeekDomain, toNum, +} from '@/components/charts/chart-format'; + +const utc = (iso: string) => new Date(`${iso}T12:00:00Z`); +const dow = (iso: string) => new Date(`${iso}T00:00:00Z`).getUTCDay(); + +describe('toNum', () => { + it('parses DECIMAL strings and passes numbers through', () => { + expect(toNum('12.50')).toBe(12.5); + expect(toNum(7)).toBe(7); + }); + it('renders everything unusable as 0 instead of NaN', () => { + expect(toNum('')).toBe(0); + expect(toNum('abc')).toBe(0); + expect(toNum(null)).toBe(0); + expect(toNum(undefined)).toBe(0); + expect(toNum(NaN)).toBe(0); + expect(toNum(Infinity)).toBe(0); + }); +}); + +describe('week domain (UTC)', () => { + it('mondayOf returns the Monday of the containing UTC week', () => { + expect(mondayOf(utc('2026-09-25'))).toBe('2026-09-21'); // Friday + expect(mondayOf(utc('2026-09-21'))).toBe('2026-09-21'); // Monday + expect(mondayOf(utc('2026-09-27'))).toBe('2026-09-21'); // Sunday belongs to the week before + expect(mondayOf(new Date('2026-09-27T23:59:59Z'))).toBe('2026-09-21'); + }); + + it('buildWeekDomain lists every Monday from the cutoff week to the current week, 7 days apart', () => { + const weeks = buildWeekDomain(utc('2026-06-27'), utc('2026-09-25')); + expect(weeks[0]).toBe('2026-06-22'); + expect(weeks[weeks.length - 1]).toBe('2026-09-21'); + expect(weeks).toHaveLength(14); + weeks.forEach(w => expect(dow(w)).toBe(1)); + for (let i = 1; i < weeks.length; i++) { + expect(Date.parse(weeks[i]) - Date.parse(weeks[i - 1])).toBe(7 * 86_400_000); + } + }); + + it('recentWeekDomain gives every chart on a page an identical week array', () => { + const now = utc('2026-09-25'); + expect(recentWeekDomain(now)).toEqual(recentWeekDomain(now)); + expect(recentWeekDomain(now)).toEqual(buildWeekDomain(new Date(now.getTime() - 90 * 86_400_000), now)); + }); +}); + +describe('fillWeeks', () => { + const weeks = ['2026-09-07', '2026-09-14', '2026-09-21']; + + it("kind 'count': a missing week is a real 0", () => { + const pts = fillWeeks(weeks, [{ week: '2026-09-14', n: 4 }], { value: r => r.n, kind: 'count' }); + expect(pts.map(p => p.value)).toEqual([0, 4, 0]); + expect(pts.map(p => p.hasData)).toEqual([false, true, false]); + }); + + it("kind 'ratio': a missing week has no value (null), so it renders as a gap", () => { + const pts = fillWeeks(weeks, [{ week: '2026-09-14', r: 2.5 }], { value: r => r.r, kind: 'ratio' }); + expect(pts.map(p => p.value)).toEqual([null, 2.5, null]); + }); + + it('isDefined returning false gives null for a ratio week that is present', () => { + const data = [{ week: '2026-09-07', avg: 0, prs: 0 }, { week: '2026-09-14', avg: 120, prs: 3 }]; + const pts = fillWeeks(weeks, data, { value: r => r.avg, kind: 'ratio', isDefined: r => r.prs > 0 }); + expect(pts.map(p => p.value)).toEqual([null, 120, null]); + expect(pts[0].hasData).toBe(false); + }); + + it('an undefined ratio value (an optional field) is a gap, not a 0', () => { + const pts = fillWeeks(weeks, [{ week: '2026-09-14' } as { week: string; v?: number }], { value: r => r.v, kind: 'ratio' }); + expect(pts[1].value).toBeNull(); + }); + + it('string numerics are converted, and rows outside the domain are ignored', () => { + const data = [{ week: '2026-08-31', n: '9' }, { week: '2026-09-21', n: '3' }]; + expect(fillWeeks(weeks, data, { value: r => r.n, kind: 'count' }).map(p => p.value)).toEqual([0, 0, 3]); + }); + + it('data entirely outside the domain (an old report) yields no week with data', () => { + const pts = fillWeeks(weeks, [{ week: '2025-01-06', n: 5 }], { value: r => r.n, kind: 'count' }); + expect(pts.some(p => p.hasData)).toBe(false); + }); + + it('two metrics filled from the same domain produce identical week sequences (syncId matches by index)', () => { + const data = [{ week: '2026-09-14', a: 1, b: 2 }]; + const a = fillWeeks(weeks, data, { value: r => r.a, kind: 'count' }).map(p => p.week); + const b = fillWeeks(weeks, data, { value: r => r.b, kind: 'ratio' }).map(p => p.week); + expect(a).toEqual(weeks); + expect(b).toEqual(weeks); + }); + + it('indexByWeek looks rows up by exact key', () => { + const idx = indexByWeek([{ week: '2026-09-07', n: 1 }]); + expect(idx.get('2026-09-07')?.n).toBe(1); + expect(idx.get('2026-09-08')).toBeUndefined(); + }); +}); + +describe('formatters', () => { + it('formatWeek prints a short UTC date', () => { + expect(formatWeek('2026-09-21')).toBe('Sep 21'); + expect(formatWeek('not-a-date')).toBe('not-a-date'); + }); + it('formatValue respects suffix and decimals, groups thousands, and shows a dash for no value', () => { + expect(formatValue(1234, { suffix: ' lines' })).toBe('1,234 lines'); + expect(formatValue(2.345, { decimals: 1 })).toBe('2.3'); + expect(formatValue(42, { suffix: '%' })).toBe('42%'); + expect(formatValue(null)).toBe('—'); + }); + it('formatCompact abbreviates axis values and keeps the sign', () => { + expect(formatCompact(1500)).toBe('1.5K'); + expect(formatCompact(-2000)).toBe('-2K'); + expect(formatCompact(12)).toBe('12'); + }); +}); + +describe('isTopOfStack', () => { + const keys = ['a', 'b', 'c']; + it('is true when every later key in the stack is zero', () => { + expect(isTopOfStack({ a: 1, b: 2, c: 0 }, keys, 'b')).toBe(true); + expect(isTopOfStack({ a: 1, b: 2, c: 3 }, keys, 'b')).toBe(false); + expect(isTopOfStack({ a: 1, b: 0, c: '0' }, keys, 'a')).toBe(true); + }); +}); diff --git a/src/lib/__tests__/unit/chart-hatch.test.tsx b/src/lib/__tests__/unit/chart-hatch.test.tsx new file mode 100644 index 00000000..6bc571da --- /dev/null +++ b/src/lib/__tests__/unit/chart-hatch.test.tsx @@ -0,0 +1,40 @@ +/** @jest-environment jsdom */ +import React from 'react'; +import { render } from '@testing-library/react'; +import { HatchSwatch, useHatch } from '@/components/charts/hatch'; + +function Probe({ colorVar }: { colorVar: string }) { + const hatch = useHatch(colorVar); + return ( + + {hatch.defs} + + ); +} + +it('gives each instance its own DOM-safe pattern id, and fill references it', () => { + const { container } = render(<>); + const patterns = Array.from(container.querySelectorAll('pattern')); + expect(patterns).toHaveLength(2); + const [a, b] = patterns.map(p => p.id); + expect(a).not.toBe(b); + patterns.forEach(p => expect(p.id).toMatch(/^hatch-[A-Za-z0-9_-]+$/)); + const fills = Array.from(container.querySelectorAll('svg[data-fill]')).map(s => s.getAttribute('data-fill')); + expect(fills).toEqual([`url(#${a})`, `url(#${b})`]); +}); + +it('draws stripes in the given colour over an explicit surface background, never transparent', () => { + const { container } = render(); + const rect = container.querySelector('pattern rect') as SVGRectElement; + const line = container.querySelector('pattern line') as SVGLineElement; + expect(rect.style.fill).toBe('var(--chart-surface)'); + expect(line.style.stroke).toBe('var(--accent)'); +}); + +it('HatchSwatch is a self-contained hatched square for HTML legends', () => { + const { container } = render(); + const pattern = container.querySelector('pattern')!; + const filled = container.querySelector('svg > rect')!; + expect(filled.getAttribute('fill')).toBe(`url(#${pattern.id})`); + expect(container.querySelector('svg')!.getAttribute('aria-hidden')).toBe('true'); +}); diff --git a/src/lib/__tests__/unit/commit-types.test.ts b/src/lib/__tests__/unit/commit-types.test.ts new file mode 100644 index 00000000..5d7fd0cd --- /dev/null +++ b/src/lib/__tests__/unit/commit-types.test.ts @@ -0,0 +1,58 @@ +import fs from 'fs'; +import path from 'path'; +import { + COMMIT_TYPE_ORDER, commitTypeBadge, commitTypeBg, commitTypeColor, foldTypes, normalizeType, typeEntriesFrom, +} from '@/components/charts/commit-types'; + +it('stacks types in the spec order', () => { + expect([...COMMIT_TYPE_ORDER]).toEqual(['feature', 'bug', 'refactor', 'infra', 'docs', 'test', 'other', 'in_flight']); +}); + +it('maps each type to its own mark token and mark background class', () => { + expect(commitTypeColor('feature')).toBe('var(--chart-type-feature)'); + expect(commitTypeColor('in_flight')).toBe('var(--chart-type-in-flight)'); + expect(commitTypeBg('bug')).toBe('bg-chart-type-bug'); + expect(commitTypeBg('in_flight')).toBe('bg-chart-type-in-flight'); +}); + +it('every type returns a badge fill and a badge text colour, separate from the mark colour', () => { + for (const t of COMMIT_TYPE_ORDER) { + const slug = t === 'in_flight' ? 'in-flight' : t; + expect(commitTypeBadge(t)).toEqual({ bg: `bg-chart-badge-${slug}-bg`, text: `text-chart-badge-${slug}-text` }); + } +}); + +it('maps unknown types to other, for marks and badges alike', () => { + expect(normalizeType('chore')).toBe('other'); + expect(commitTypeColor('chore')).toBe('var(--chart-type-other)'); + expect(commitTypeBg('')).toBe('bg-chart-type-other'); + expect(commitTypeBadge('chore')).toEqual(commitTypeBadge('other')); +}); + +it('foldTypes sums across weeks, folds unknown types into other, and tolerates string counts', () => { + const folded = foldTypes([{ feature: 2, chore: 1 }, { feature: '3', other: 1, in_flight: 4 }]); + expect(folded.feature).toBe(5); + expect(folded.other).toBe(2); + expect(folded.in_flight).toBe(4); + expect(folded.bug).toBe(0); +}); + +it('typeEntriesFrom gives one row per non-zero type in fixed COMMIT_TYPE_ORDER, not by count, with unknown types in a single other row', () => { + expect(typeEntriesFrom([{ feature: 2, chore: 1, other: 1 }, { bug: 3 }, { in_flight: 9 }])).toEqual([ + ['feature', 2], ['bug', 3], ['other', 2], ['in_flight', 9], + ]); + expect(typeEntriesFrom([])).toEqual([]); +}); + +// The palette gate could move a hue in the charts while a page kept an old copy of the map. +it.skip('no report page keeps its own commit-type colour map', () => { + const files = [ + 'src/app/report/[id]/org/page.tsx', + 'src/app/report/[id]/dev/[login]/page.tsx', + 'src/app/report/[id]/team/dev-table.tsx', + ]; + const offenders = files.filter(f => + /const\s+TYPE_(COLORS|HEX|TEXT_COLORS)\b/.test(fs.readFileSync(path.join(__dirname, '../../../..', f), 'utf8')), + ); + expect(offenders).toEqual([]); +}); diff --git a/src/lib/__tests__/unit/week-key-utc.test.ts b/src/lib/__tests__/unit/week-key-utc.test.ts new file mode 100644 index 00000000..5176ed63 --- /dev/null +++ b/src/lib/__tests__/unit/week-key-utc.test.ts @@ -0,0 +1,35 @@ +// GLOOK-58 Decision 10: weekKeyForDate keys by the containing UTC week, whatever the server's +// time zone. Assigning process.env.TZ inside a Jest test does not change Date's zone (Jest gives +// the test a copy of process.env, and Node resets its zone cache only on the real one), so the +// time-zone case runs in a child process that starts with TZ set. +import { execFileSync } from 'child_process'; +import path from 'path'; +import { weekKeyForDate } from '@/lib/report/timeline'; + +const root = path.join(__dirname, '../../../..'); +// Monday 2026-09-21 21:00 in New York (EDT, UTC-4) is Tuesday 2026-09-22 01:00 UTC. +const MONDAY_9PM_NEW_YORK = '2026-09-22T01:00:00Z'; + +function keyUnderTz(tz: string, iso: string): string { + const script = `import { weekKeyForDate } from './src/lib/report/timeline'; console.log(weekKeyForDate(new Date('${iso}')));`; + return execFileSync(path.join(root, 'node_modules/.bin/tsx'), ['-e', script], { + cwd: root, + env: { ...process.env, TZ: tz }, + encoding: 'utf8', + }).trim(); +} + +it('a Monday-21:00 commit under TZ=America/New_York keys to that UTC week, not the next day', () => { + expect(keyUnderTz('America/New_York', MONDAY_9PM_NEW_YORK)).toBe('2026-09-21'); +}, 30_000); + +it('the same instant keys identically in every server time zone', () => { + expect(keyUnderTz('Asia/Tokyo', MONDAY_9PM_NEW_YORK)).toBe('2026-09-21'); + expect(keyUnderTz('UTC', MONDAY_9PM_NEW_YORK)).toBe('2026-09-21'); +}, 30_000); + +it('keys a UTC Monday, midweek day and Sunday to that UTC Monday (in-process)', () => { + expect(weekKeyForDate(new Date('2026-09-21T00:00:00Z'))).toBe('2026-09-21'); + expect(weekKeyForDate(new Date('2026-09-24T12:00:00Z'))).toBe('2026-09-21'); + expect(weekKeyForDate(new Date('2026-09-27T23:59:59Z'))).toBe('2026-09-21'); +}); diff --git a/src/lib/report/timeline.ts b/src/lib/report/timeline.ts index 7eb4fefe..0d7f9428 100644 --- a/src/lib/report/timeline.ts +++ b/src/lib/report/timeline.ts @@ -18,13 +18,16 @@ export interface WeeklyBucket { avgImpact?: number; } -// ISO date string for the Monday of the week containing `d`. Used by both the -// shipped-commit aggregator below and the in-flight overlay in `org.ts`, so the -// two paths can't drift on what counts as the same week. +// ISO date string for the Monday of the UTC week containing `d`. Used by both the shipped-commit +// aggregator below and the in-flight overlay in `org.ts`, so the two paths can't drift on what +// counts as the same week. GLOOK-58: UTC throughout. The old local-time arithmetic formatted with +// toISOString(), so on a server west of UTC a Monday-evening commit got a Tuesday key and one week +// arrived as two buckets. No week keys are persisted (timelines are computed per request), so this +// needs no migration. export function weekKeyForDate(d: Date): string { - const day = d.getDay(); + const day = d.getUTCDay(); const monday = new Date(d); - monday.setDate(d.getDate() - ((day + 6) % 7)); + monday.setUTCDate(d.getUTCDate() - ((day + 6) % 7)); return monday.toISOString().split('T')[0]; } From c817aef509feed4d7aebe048efe761f6f622b366 Mon Sep 17 00:00:00 2001 From: Evgeniy Nepeipivo Date: Fri, 25 Sep 2026 13:25:30 -0400 Subject: [PATCH 14/53] GLOOK-58: discriminating week-key tests and shared cssIdent in hatch Co-Authored-By: Claude Sonnet 5 --- src/components/charts/chart.tsx | 3 +- src/components/charts/hatch.tsx | 3 +- src/lib/__tests__/unit/week-key-utc.test.ts | 54 +++++++++++++++++++-- 3 files changed, 55 insertions(+), 5 deletions(-) diff --git a/src/components/charts/chart.tsx b/src/components/charts/chart.tsx index 1cf06320..c137f327 100644 --- a/src/components/charts/chart.tsx +++ b/src/components/charts/chart.tsx @@ -19,7 +19,8 @@ const INITIAL_DIMENSION = { width: 320, height: 200 } as const; // ChartStyle writes ids, keys and colours into a raw

Activity Over Time (weekly)

+ + - - toNum(d.prs) > 0} label="Avg Lines Changed / PR (outliers excluded)" - color="#EC4899" suffix=" lines" + syncId="dev-timeline" /> d.linesAdded + d.linesRemoved} - /> - toNum(d.linesAdded) + toNum(d.linesRemoved)} + syncId="dev-timeline" /> + toNum(d.commits) > 0} label="AI Assisted %" - color="#A855F7" suffix="%" + syncId="dev-timeline" />
@@ -664,179 +660,3 @@ export default function DevDetailPage() {
); } - -function TimelineChart({ - data, - valueKey, - label, - color, - suffix = '', - decimals = 0, - computeValue, -}: { - data: WeeklyData[]; - valueKey: string; - label: string; - color: string; - suffix?: string; - decimals?: number; - computeValue?: (d: WeeklyData) => number; -}) { - const [hoverIdx, setHoverIdx] = useState(null); - - // Last 90 days of data - const cutoff = new Date(); - cutoff.setDate(cutoff.getDate() - 90); - const cutoffStr = cutoff.toISOString().split('T')[0]; - const filtered = data.filter(d => d.week >= cutoffStr); - - if (filtered.length < 1) return null; - - const values = filtered.map(d => computeValue ? computeValue(d) : (d as any)[valueKey] as number); - const max = Math.max(...values, 1); - const min = Math.min(...values, 0); - const range = max - min || 1; - - // Y-axis: pick nice round tick values - const yTicks: number[] = []; - const step = range <= 5 ? 1 : range <= 20 ? 5 : range <= 100 ? 20 : range <= 500 ? 100 : range <= 2000 ? 500 : Math.ceil(range / 5 / 100) * 100; - for (let v = Math.ceil(min / step) * step; v <= max; v += step) { - yTicks.push(v); - } - if (yTicks.length === 0) yTicks.push(min, max); - if (yTicks.length > 6) { - const keep = [yTicks[0], yTicks[Math.floor(yTicks.length / 2)], yTicks[yTicks.length - 1]]; - yTicks.length = 0; - yTicks.push(...keep); - } - - const W = 400; - const H = 130; - const padL = 40; - const padR = 12; - const padT = 12; - const padB = 24; - const chartW = W - padL - padR; - const chartH = H - padT - padB; - - // Fixed X-axis range: [cutoff, today]. - // d.week is a Monday-anchored ISO date string (YYYY-MM-DD) from weekKeyForDate() in timeline.ts. - // Parse with T00:00:00 to stay in local time, consistent with formatWeek below. - const today = new Date(); - const totalMs = today.getTime() - cutoff.getTime(); - const totalWeeks = totalMs / (7 * 24 * 3600 * 1000); - const barWidth = (chartW / totalWeeks) * 0.85; - - const bars = filtered.map((d, i) => { - const x = padL + ((new Date(d.week + 'T00:00:00').getTime() - cutoff.getTime()) / totalMs) * chartW; - const v = values[i]; - const barH = Math.max(((v - min) / range) * chartH, 1); - const barY = padT + chartH - barH; - return { x, barY, barH, v, week: d.week }; - }); - - const formatWeek = (w: string) => { - const d = new Date(w + 'T00:00:00'); - return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); - }; - const formatVal = (v: number) => (decimals > 0 ? v.toFixed(decimals) : String(Math.round(v))) + suffix; - - // X-axis labels: left = cutoff (axis origin), middle = midpoint of range, right = "This week" - const middleDate = new Date(cutoff.getTime() + totalMs / 2); - const middleLabel = middleDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); - - const latest = values[values.length - 1]; - const prev = values.length >= 2 ? values[values.length - 2] : latest; - const trend = latest > prev ? '+' : latest < prev ? '' : ''; - const diff = latest - prev; - - return ( -
-
-

{label}

-
- - {formatVal(latest)} - - {diff !== 0 && ( - 0 ? 'text-green-400' : 'text-red-400'}`}> - {trend}{formatVal(Math.abs(diff))} - - )} -
-
- - {/* Grid lines + Y-axis labels */} - {yTicks.map(v => { - const y = padT + chartH - ((v - min) / range) * chartH; - return ( - - - - {decimals > 0 ? v.toFixed(decimals) : v}{suffix} - - - ); - })} - {/* Bars — color prop, opacity 1, no corner radius */} - {bars.map((bar, i) => ( - - ))} - {/* Full-column invisible hover targets (rendered after bars to sit on top) */} - {bars.map((bar, i) => ( - setHoverIdx(i)} - onMouseLeave={() => setHoverIdx(null)} - /> - ))} - {/* Hover tooltip */} - {hoverIdx !== null && (() => { - const bar = bars[hoverIdx]; - const weekLabel = formatWeek(bar.week); - const valLabel = formatVal(bar.v); - const text = `${weekLabel}: ${valLabel}`; - const textW = text.length * 6 + 16; - const tooltipX = Math.min(Math.max(bar.x - textW / 2, 2), W - textW - 2); - const above = bar.barY > padT + 30; - const tooltipY = above ? bar.barY - 28 : bar.barY + 12; - return ( - - {/* Vertical guide line */} - - {/* Tooltip background */} - - {/* Tooltip text */} - - {text} - - - ); - })()} - {/* X-axis labels: left = cutoff, middle = midpoint, right = "This week" */} - - {formatWeek(cutoffStr)} - - - {middleLabel} - - - This week - - -
- ); -} diff --git a/src/app/report/[id]/org/page.tsx b/src/app/report/[id]/org/page.tsx index 01a2c331..ca310b50 100644 --- a/src/app/report/[id]/org/page.tsx +++ b/src/app/report/[id]/org/page.tsx @@ -7,6 +7,8 @@ import ChatPanel from '@/app/chat-panel'; import IntegrityBadge from '@/components/IntegrityBadge'; import { useUrlState } from '@/lib/url-state'; import { SpendTab, type Developer, type ReportMeta, type SpendWindow, type ModelUsageRow, type SkillsUsageRow } from './spend-tab'; +import { TimelineChart } from '@/components/charts/timeline-chart'; +import { recentWeekDomain, toNum } from '@/components/charts/chart-format'; const TYPE_COLORS: Record = { feature: 'bg-blue-500', @@ -97,6 +99,9 @@ export default function OrgDetailPage() { const typeEntries = Object.entries(orgTypes).sort((a, b) => b[1] - a[1]); const totalTyped = typeEntries.reduce((s, [, c]) => s + c, 0); + // One week domain per render, shared by every chart below so hover sync (syncId) lines up. + const weeks = recentWeekDomain(new Date()); + const hasJira = developers.some(d => (d.total_jira_issues ?? 0) > 0); // Spend tab exists only when there is real spend to show. `!= null` alone is // always true (cc_total_cost is NOT NULL DEFAULT 0), so installs with no @@ -252,27 +257,36 @@ export default function OrgDetailPage() {
d.types?.in_flight ?? 0} + syncId="org-timeline" /> + - toNum(d.prs) > 0} label="Avg Lines Changed / PR (outliers excluded)" - color="#EC4899" suffix=" lines" + syncId="org-timeline" /> - + - + toNum(d.commits) > 0} + label="AI Assisted %" + suffix="%" + syncId="org-timeline" + />
)} @@ -780,143 +794,3 @@ function PieChart({ entries, total }: { entries: [string, number][]; total: numb ); } - -// Reusable timeline chart (same as developer detail page) -function TimelineChart({ - data, - valueKey, - label, - color, - suffix = '', - decimals = 0, - computeValue, - inFlightValue, -}: { - data: WeeklyData[]; - valueKey: string; - label: string; - color: string; - suffix?: string; - decimals?: number; - computeValue?: (d: WeeklyData) => number; - // Optional: per-week in-flight portion. When provided, each bar is rendered as - // a stacked pair: shipped (color, bottom) + in-flight (amber, top). - inFlightValue?: (d: WeeklyData) => number; -}) { - const [hoverIdx, setHoverIdx] = useState(null); - - const cutoff = new Date(); - cutoff.setDate(cutoff.getDate() - 90); - const cutoffStr = cutoff.toISOString().split('T')[0]; - const filtered = data.filter(d => d.week >= cutoffStr); - - if (filtered.length < 2) return null; - - const values = filtered.map(d => computeValue ? computeValue(d) : ((d as any)[valueKey] as number) ?? 0); - const max = Math.max(...values, 1); - const min = Math.min(...values, 0); - const range = max - min || 1; - - const yTicks: number[] = []; - const step = range <= 5 ? 1 : range <= 20 ? 5 : range <= 100 ? 20 : range <= 500 ? 100 : range <= 2000 ? 500 : Math.ceil(range / 5 / 100) * 100; - for (let v = Math.ceil(min / step) * step; v <= max; v += step) yTicks.push(v); - if (yTicks.length === 0) yTicks.push(min, max); - if (yTicks.length > 6) { - const keep = [yTicks[0], yTicks[Math.floor(yTicks.length / 2)], yTicks[yTicks.length - 1]]; - yTicks.length = 0; - yTicks.push(...keep); - } - - const W = 400, H = 130; - const padL = 40, padR = 12, padT = 12, padB = 24; - const chartW = W - padL - padR, chartH = H - padT - padB; - - const barW = Math.max(4, (chartW / values.length) * 0.75); - const barGap = (chartW / values.length) - barW; - const xFor = (i: number) => padL + i * (barW + barGap) + barW / 2; - - const labelIndices = [0, Math.floor(filtered.length / 2), filtered.length - 1]; - const formatWeek = (w: string) => { - const d = new Date(w + 'T00:00:00'); - return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); - }; - const formatVal = (v: number) => (decimals > 0 ? v.toFixed(decimals) : String(Math.round(v))) + suffix; - - const latest = values[values.length - 1]; - const prev = values.length >= 2 ? values[values.length - 2] : latest; - const diff = latest - prev; - const trend = latest > prev ? '+' : latest < prev ? '' : ''; - - return ( -
-
-

{label}

-
- {formatVal(latest)} - {diff !== 0 && ( - 0 ? 'text-green-400' : 'text-red-400'}`}> - {trend}{formatVal(Math.abs(diff))} - - )} -
-
- - {yTicks.map(v => { - const y = padT + chartH - ((v - min) / range) * chartH; - return ( - - - - {decimals > 0 ? v.toFixed(decimals) : v}{suffix} - - - ); - })} - {values.map((v, i) => { - const barH = range > 0 ? ((v - min) / range) * chartH : 0; - const x = xFor(i); - const y = padT + chartH - barH; - const inFlight = inFlightValue ? inFlightValue(filtered[i]) : 0; - const inFlightH = range > 0 && inFlight > 0 ? (inFlight / range) * chartH : 0; - const shippedH = Math.max(0, barH - inFlightH); - return ( - setHoverIdx(i)} onMouseLeave={() => setHoverIdx(null)}> - - {/* shipped portion (bottom) */} - - {/* in-flight portion (top) */} - {inFlightH > 0 && ( - - )} - - ); - })} - {hoverIdx !== null && (() => { - const v = values[hoverIdx]; - const barH = range > 0 ? ((v - min) / range) * chartH : 0; - const x = xFor(hoverIdx); - const y = padT + chartH - barH; - const text = `${formatWeek(filtered[hoverIdx].week)}: ${formatVal(v)}`; - const textW = text.length * 6 + 16; - const tooltipX = Math.min(Math.max(x - textW / 2, 2), W - textW - 2); - const above = y > padT + 30; - const tooltipY = above ? y - 24 : y + barH + 8; - return ( - - - {text} - - ); - })()} - {labelIndices.map(idx => ( - - {formatWeek(filtered[idx].week)} - - ))} - -
- ); -} diff --git a/src/components/charts/timeline-chart.tsx b/src/components/charts/timeline-chart.tsx new file mode 100644 index 00000000..635612d7 --- /dev/null +++ b/src/components/charts/timeline-chart.tsx @@ -0,0 +1,126 @@ +'use client'; + +// GLOOK-58: the one TimelineChart (it used to exist twice, in the org and dev pages, and the +// copies had diverged). Bars are placed by date on the page's shared week domain, so every chart +// in a grid has the same array and hover sync (syncId, matched by index) lines up. +import { Bar, BarChart, CartesianGrid, Rectangle, XAxis, YAxis, type BarShapeProps, type RectangleProps } from 'recharts'; +import type { TooltipContentProps, TooltipValueType } from 'recharts'; +import { ChartContainer, ChartTooltip, CHART_TOOLTIP_CLASS } from './chart'; +import { fillWeeks, formatCompact, formatValue, formatWeek, type MetricKind } from './chart-format'; +import { useHatch } from './hatch'; + +export interface TimelineRow { + week: string; +} + +export interface TimelinePoint { + week: string; + value: number | null; + shipped: number | null; + inFlight: number | null; +} + +export interface TimelineChartProps { + data: T[]; + /** The page's shared week domain (recentWeekDomain()). */ + weeks: string[]; + valueKey?: keyof T & string; + computeValue?: (row: T) => unknown; + kind: MetricKind; + isDefined?: (row: T) => boolean; + label: string; + suffix?: string; + decimals?: number; + /** Per-week in-flight portion of the value (a part of the total, not an addition to it). */ + inFlightValue?: (row: T) => unknown; + syncId: string; +} + +type TimelineTooltipProps = Partial> & { + suffix?: string; + decimals?: number; + split?: boolean; +}; + +export function TimelineTooltip({ active, payload, suffix = '', decimals = 0, split = false }: TimelineTooltipProps) { + const row = payload?.[0]?.payload as TimelinePoint | undefined; + if (!active || !row || row.value === null) return null; + const fmt = (v: number) => formatValue(v, { suffix, decimals }); + const inFlight = row.inFlight ?? 0; + return ( +
+
{formatWeek(row.week)}
+
{fmt(row.value)}
+ {split && inFlight > 0 && ( + <> +
Shipped{fmt(row.shipped ?? 0)}
+
In flight{fmt(inFlight)}
+ + )} +
+ ); +} + +function shippedShape(props: BarShapeProps) { + const row = props.payload as TimelinePoint; + const top = !((row.inFlight ?? 0) > 0); + return ; +} + +export function TimelineChart({ + data, weeks, valueKey, computeValue, kind, isDefined, label, suffix = '', decimals = 0, inFlightValue, syncId, +}: TimelineChartProps) { + const hatch = useHatch('var(--accent)'); + const read = (row: T): unknown => (computeValue ? computeValue(row) : valueKey ? row[valueKey] : undefined); + const points = fillWeeks(weeks, data, { value: read, kind, isDefined }); + const inFlight = inFlightValue ? fillWeeks(weeks, data, { value: inFlightValue, kind: 'count' }) : null; + + const rows: TimelinePoint[] = points.map((p, i) => { + if (p.value === null) return { week: p.week, value: null, shipped: null, inFlight: null }; + const f = inFlight ? Math.min(Math.max(0, inFlight[i].value ?? 0), p.value) : 0; + return { week: p.week, value: p.value, shipped: p.value - f, inFlight: f }; + }); + + const withData = points.filter(p => p.hasData); + const latest = withData.length > 0 ? withData[withData.length - 1].value : null; + const prev = withData.length > 1 ? withData[withData.length - 2].value : null; + const diff = latest !== null && prev !== null ? latest - prev : 0; + const fmt = (v: number) => formatValue(v, { suffix, decimals }); + + return ( +
+
+

{label}

+ {latest !== null && ( +
+ {fmt(latest)} + {diff !== 0 && ( + 0 ? 'text-green-400' : 'text-red-400'}`}> + {diff > 0 ? '+' : '−'}{fmt(Math.abs(diff))} + + )} +
+ )} +
+ {withData.length === 0 ? ( +

No data in the last 90 days

+ ) : ( + + + {inFlightValue && hatch.defs} + + + 0} tickFormatter={formatCompact} /> + } /> + + {inFlightValue && ( + + )} + + + )} +
+ ); +} diff --git a/src/lib/__tests__/unit/timeline-chart.test.tsx b/src/lib/__tests__/unit/timeline-chart.test.tsx new file mode 100644 index 00000000..8a0472f1 --- /dev/null +++ b/src/lib/__tests__/unit/timeline-chart.test.tsx @@ -0,0 +1,115 @@ +/** @jest-environment jsdom */ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { TimelineChart, TimelineTooltip } from '@/components/charts/timeline-chart'; +import { fixChartSize } from '../setup/chart-size'; + +fixChartSize(); + +const weeks = ['2026-08-31', '2026-09-07', '2026-09-14', '2026-09-21']; +type Row = { week: string; commits: number | string; prs: number; avg?: number | string; types?: Record }; + +const rects = (c: HTMLElement) => Array.from(c.querySelectorAll('.recharts-bar-rectangle path.recharts-rectangle')); + +it("kind 'count': one accent bar per week with a value, zero weeks draw nothing", () => { + const data: Row[] = [{ week: '2026-09-07', commits: 3, prs: 1 }, { week: '2026-09-21', commits: 5, prs: 2 }]; + const { container } = render(); + expect(rects(container)).toHaveLength(2); + rects(container).forEach(r => expect(r.getAttribute('fill')).toBe('var(--accent)')); +}); + +it("kind 'ratio' with isDefined: a present week with prs === 0 is a gap, not a 0 bar", () => { + const data: Row[] = [ + { week: '2026-09-07', commits: 2, prs: 0, avg: 0 }, + { week: '2026-09-14', commits: 2, prs: 2, avg: 80 }, + { week: '2026-09-21', commits: 2, prs: 1, avg: 40 }, + ]; + const { container } = render( + d.prs > 0} label="Avg" suffix=" lines" syncId="t" />, + ); + expect(rects(container)).toHaveLength(2); +}); + +it('the header shows the latest week with data and its change from the previous one', () => { + const data: Row[] = [{ week: '2026-09-07', commits: 3, prs: 0 }, { week: '2026-09-14', commits: 5, prs: 0 }]; + render(); + // Scoped by test id: y-axis tick text can also render a bare "5". + expect(screen.getByTestId('timeline-latest').textContent).toBe('5'); + expect(screen.getByTestId('timeline-change').textContent).toBe('+2'); +}); + +it('a downward change carries a minus sign, so direction is not shown by colour alone', () => { + const data: Row[] = [{ week: '2026-09-07', commits: 5, prs: 0 }, { week: '2026-09-14', commits: 2, prs: 0 }]; + render(); + expect(screen.getByTestId('timeline-change').textContent).toBe('−3'); +}); + +it('string DECIMAL values render as numbers in the header', () => { + const data: Row[] = [{ week: '2026-09-14', commits: 1, prs: 1, avg: '2.50' }]; + render(); + expect(screen.getByTestId('timeline-latest').textContent).toBe('2.5'); +}); + +it('the hatch appears only with inFlightValue, and the in-flight segment uses it', () => { + const data: Row[] = [{ week: '2026-09-14', commits: 5, prs: 0, types: { feature: 3, in_flight: 2 } }]; + const plain = render(); + expect(plain.container.querySelector('pattern')).toBeNull(); + plain.unmount(); + + const { container } = render( + d.types?.in_flight ?? 0} />, + ); + const pattern = container.querySelector('pattern')!; + const fills = rects(container).map(r => r.getAttribute('fill')); + expect(fills).toEqual(['var(--accent)', `url(#${pattern.id})`]); +}); + +it('in-flight larger than the week total is clamped: the stack is all hatch, never negative', () => { + const data: Row[] = [{ week: '2026-09-14', commits: 2, prs: 0, types: { in_flight: 9 } }]; + const { container } = render( + d.types?.in_flight ?? 0} />, + ); + const fills = rects(container).map(r => r.getAttribute('fill')); + expect(fills).toHaveLength(1); + expect(fills[0]).toMatch(/^url\(#hatch-/); +}); + +it('two instances on one page get different pattern ids', () => { + const data: Row[] = [{ week: '2026-09-14', commits: 5, prs: 0, types: { in_flight: 2 } }]; + const { container } = render( + <> + d.types?.in_flight ?? 0} /> + d.types?.in_flight ?? 0} /> + , + ); + const ids = Array.from(container.querySelectorAll('pattern')).map(p => p.id); + expect(ids).toHaveLength(2); + expect(ids[0]).not.toBe(ids[1]); +}); + +it('shows an explicit empty state when no week has data', () => { + render(); + expect(screen.getByText('No data in the last 90 days')).toBeTruthy(); +}); + +it('shows the empty state, not a blank card, for an old report whose weeks are all outside the domain', () => { + const data: Row[] = [{ week: '2025-01-06', commits: 7, prs: 1 }]; + const { container } = render(); + expect(screen.getByText('No data in the last 90 days')).toBeTruthy(); + expect(container.querySelector('svg.recharts-surface')).toBeNull(); +}); + +it('the tooltip shows the week, the value, and the shipped / in-flight split when present', () => { + const row = { week: '2026-09-21', value: 5, shipped: 3, inFlight: 2 }; + const { container } = render(); + expect(screen.getByText('Sep 21')).toBeTruthy(); + expect(container.textContent).toContain('Shipped3'); + expect(container.textContent).toContain('In flight2'); +}); + +it('the tooltip omits the split when the week has no in-flight work', () => { + const row = { week: '2026-09-21', value: 5, shipped: 5, inFlight: 0 }; + const { container } = render(); + expect(container.textContent).toContain('5%'); + expect(container.textContent).not.toContain('In flight'); +}); From b4a89c9b0aff2f6602db076a6bd3f4350eee6481 Mon Sep 17 00:00:00 2001 From: Evgeniy Nepeipivo Date: Fri, 25 Sep 2026 13:44:59 -0400 Subject: [PATCH 17/53] GLOOK-58: TimelineChart edge-case tests for undefined latest ratio and negative in-flight Co-Authored-By: Claude Opus 5.5 --- .../__tests__/unit/timeline-chart.test.tsx | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/lib/__tests__/unit/timeline-chart.test.tsx b/src/lib/__tests__/unit/timeline-chart.test.tsx index 8a0472f1..7fcfc0f5 100644 --- a/src/lib/__tests__/unit/timeline-chart.test.tsx +++ b/src/lib/__tests__/unit/timeline-chart.test.tsx @@ -113,3 +113,35 @@ it('the tooltip omits the split when the week has no in-flight work', () => { expect(container.textContent).toContain('5%'); expect(container.textContent).not.toContain('In flight'); }); + +it('ratio: when the latest domain week fails isDefined, the header uses the last week that has data', () => { + const data: Row[] = [ + { week: '2026-09-07', commits: 2, prs: 1, avg: 40 }, + { week: '2026-09-14', commits: 2, prs: 2, avg: 80 }, + { week: '2026-09-21', commits: 2, prs: 0, avg: 0 }, + ]; + render( d.prs > 0} label="Avg" suffix=" lines" syncId="t" />); + const latest = screen.getByTestId('timeline-latest').textContent; + const change = screen.getByTestId('timeline-change').textContent; + expect(latest).toBe('80 lines'); + expect(change).toBe('+40 lines'); + for (const t of [latest, change]) { + expect(t).not.toBe(''); + expect(t).not.toContain('NaN'); + } +}); + +it('a negative in-flight value clamps to 0: no hatched segment, shipped bar identical to a chart without in-flight', () => { + const data: Row[] = [{ week: '2026-09-14', commits: 5, prs: 0, types: { in_flight: -3 } }]; + const plain = render(); + const plainD = rects(plain.container).map(r => r.getAttribute('d')); + plain.unmount(); + + const { container } = render( + d.types?.in_flight ?? 0} />, + ); + const drawn = rects(container); + expect(drawn.map(r => r.getAttribute('fill'))).toEqual(['var(--accent)']); + // Same geometry (height and rounded top) as the plain chart, so shipped is still the full 5. + expect(drawn.map(r => r.getAttribute('d'))).toEqual(plainD); +}); From 145d950f63e5da4b0ca6d4eca522bec941948a32 Mon Sep 17 00:00:00 2001 From: Evgeniy Nepeipivo Date: Fri, 25 Sep 2026 13:58:03 -0400 Subject: [PATCH 18/53] GLOOK-58: stacked types, diverging lines and donut charts on the org page Adds StackedTypesChart, LinesChangedChart and CommitTypeDonut (Recharts), folds the org page's type breakdown through typeEntriesFrom, and deletes the old TYPE_COLORS/TYPE_HEX maps and hand-rolled SVG chart functions. The donut's slice-hover test (Step 7) passed in jsdom and was kept. Co-Authored-By: Claude Sonnet 5 --- src/app/report/[id]/org/page.tsx | 423 +----------------- src/components/charts/commit-type-donut.tsx | 89 ++++ src/components/charts/lines-changed-chart.tsx | 114 +++++ src/components/charts/stacked-types-chart.tsx | 84 ++++ .../__tests__/unit/commit-type-donut.test.tsx | 81 ++++ .../unit/lines-changed-chart.test.tsx | 74 +++ .../unit/stacked-types-chart.test.tsx | 58 +++ 7 files changed, 513 insertions(+), 410 deletions(-) create mode 100644 src/components/charts/commit-type-donut.tsx create mode 100644 src/components/charts/lines-changed-chart.tsx create mode 100644 src/components/charts/stacked-types-chart.tsx create mode 100644 src/lib/__tests__/unit/commit-type-donut.test.tsx create mode 100644 src/lib/__tests__/unit/lines-changed-chart.test.tsx create mode 100644 src/lib/__tests__/unit/stacked-types-chart.test.tsx diff --git a/src/app/report/[id]/org/page.tsx b/src/app/report/[id]/org/page.tsx index ca310b50..8e3b52e9 100644 --- a/src/app/report/[id]/org/page.tsx +++ b/src/app/report/[id]/org/page.tsx @@ -9,28 +9,10 @@ import { useUrlState } from '@/lib/url-state'; import { SpendTab, type Developer, type ReportMeta, type SpendWindow, type ModelUsageRow, type SkillsUsageRow } from './spend-tab'; import { TimelineChart } from '@/components/charts/timeline-chart'; import { recentWeekDomain, toNum } from '@/components/charts/chart-format'; - -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', - in_flight: 'bg-cyan-500', -}; - -const TYPE_HEX: Record = { - feature: '#3B82F6', - bug: '#EF4444', - refactor: '#A855F7', - infra: '#EAB308', - docs: '#6B7280', - test: '#22C55E', - other: '#4B5563', - in_flight: '#06B6D4', -}; +import { StackedTypesChart } from '@/components/charts/stacked-types-chart'; +import { LinesChangedChart } from '@/components/charts/lines-changed-chart'; +import { CommitTypeDonut } from '@/components/charts/commit-type-donut'; +import { typeEntriesFrom } from '@/components/charts/commit-types'; interface WeeklyData { week: string; commits: number; prs: number; avgLinesPerPr: number; linesAdded: number; linesRemoved: number; @@ -87,16 +69,10 @@ export default function OrgDetailPage() { const avgImpact = developers.length > 0 ? developers.reduce((s, d) => s + Number(d.impact_score), 0) / developers.length : 0; - // Type breakdown — sum across all timeline weeks. timeline already has the - // per-commit in_flight override applied server-side in getOrgReport, so the - // pie inherits the in_flight slice automatically. - const orgTypes: Record = {}; - for (const week of timeline) { - for (const [type, count] of Object.entries(week.types || {})) { - orgTypes[type] = (orgTypes[type] || 0) + (count as number); - } - } - const typeEntries = Object.entries(orgTypes).sort((a, b) => b[1] - a[1]); + // Type breakdown — folded across all timeline weeks, so an unrecognized type joins `other` + // instead of becoming a second, identically coloured wedge. timeline already carries the + // per-commit in_flight override (applied server-side in getOrgReport). + const typeEntries = typeEntriesFrom(timeline.map(w => w.types ?? {})); const totalTyped = typeEntries.reduce((s, [, c]) => s + c, 0); // One week domain per render, shared by every chart below so hover sync (syncId) lines up. @@ -195,10 +171,10 @@ export default function OrgDetailPage() { {/* Type Breakdown + Active Repos */}
- {/* Type Breakdown — Pie Chart */} -
+ {/* Type Breakdown — Donut */} +

Commit Types (org-wide)

- {totalTyped > 0 &&
} +
{/* Active Repos */} @@ -276,7 +252,7 @@ export default function OrgDetailPage() { syncId="org-timeline" /> - + = 2 && } + {timeline.length >= 2 && } {/* Top Developers Table — hidden, use Team Summary instead */} {false &&
@@ -421,376 +397,3 @@ function JiraIssuesPopover({ reportId, login, count }: { reportId: string; login
); } - -function StackedTypesChart({ data }: { data: WeeklyData[] }) { - const [hoverIdx, setHoverIdx] = useState(null); - - const cutoff = new Date(); - cutoff.setDate(cutoff.getDate() - 90); - const cutoffStr = cutoff.toISOString().split('T')[0]; - const filtered = data.filter(d => d.week >= cutoffStr); - if (filtered.length < 2) return null; - - const allTypes = new Set(); - for (const w of filtered) { for (const t of Object.keys(w.types)) allTypes.add(t); } - const typeOrder = ['feature', 'bug', 'refactor', 'infra', 'docs', 'test', 'other', 'in_flight'].filter(t => allTypes.has(t)); - - const stacked = filtered.map(w => { - const total = typeOrder.reduce((s, t) => s + (w.types[t] || 0), 0); - let cumulative = 0; - const layers = typeOrder.map(t => { - const val = w.types[t] || 0; - const y0 = cumulative; - cumulative += val; - return { type: t, val, y0, y1: cumulative }; - }); - return { week: w.week, total, layers }; - }); - - const maxTotal = Math.max(...stacked.map(s => s.total), 1); - - const W = 800; - const H = 180; - const padL = 40; - const padR = 12; - const padT = 12; - const padB = 24; - const chartW = W - padL - padR; - const chartH = H - padT - padB; - - const barW = Math.max(4, (chartW / filtered.length) * 0.75); - const barGap = (chartW / filtered.length) - barW; - const xFor = (i: number) => padL + i * (barW + barGap) + barGap / 2; - const yFor = (val: number) => padT + chartH - (val / maxTotal) * chartH; - - const yTicks: number[] = []; - const step = maxTotal <= 10 ? 2 : maxTotal <= 50 ? 10 : maxTotal <= 200 ? 50 : 100; - for (let v = 0; v <= maxTotal; v += step) yTicks.push(v); - - const formatWeek = (w: string) => { - const d = new Date(w + 'T00:00:00'); - return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); - }; - const labelStep = Math.max(1, Math.floor(filtered.length / 6)); - const labelIndices = filtered.map((_, i) => i).filter(i => i % labelStep === 0 || i === filtered.length - 1); - - return ( -
-
-

Commit Types Over Time (weekly)

-
- {typeOrder.map(t => ( - - - {t} - - ))} -
-
- - {/* Y grid + labels */} - {yTicks.map(v => { - const y = yFor(v); - return ( - - - {v} - - ); - })} - {/* Stacked bars */} - {stacked.map((s, i) => ( - - {s.layers.map(layer => { - if (layer.val === 0) return null; - const barH = (layer.val / maxTotal) * chartH; - const y = yFor(layer.y1); - return ( - - ); - })} - {/* Invisible hover target */} - setHoverIdx(i)} - onMouseLeave={() => setHoverIdx(null)} - /> - - ))} - {/* Hover tooltip */} - {hoverIdx !== null && (() => { - const s = stacked[hoverIdx]; - const x = xFor(hoverIdx) + barW / 2; - const lines = [`${formatWeek(s.week)} — ${s.total} total`, ...s.layers.filter(l => l.val > 0).map(l => `${l.type}: ${l.val}`)]; - const textW = Math.max(...lines.map(l => l.length)) * 5.5 + 24; - const tooltipX = Math.min(Math.max(x - textW / 2, 2), W - textW - 2); - return ( - - - - {lines.map((line, li) => ( - - {line} - - ))} - - ); - })()} - {/* X labels */} - {labelIndices.map(idx => ( - - {formatWeek(filtered[idx].week)} - - ))} - -
- ); -} - -function LinesChangedChart({ data }: { data: WeeklyData[] }) { - const [hoverIdx, setHoverIdx] = useState(null); - - const cutoff = new Date(); - cutoff.setDate(cutoff.getDate() - 90); - const cutoffStr = cutoff.toISOString().split('T')[0]; - const filtered = data.filter(d => d.week >= cutoffStr); - if (filtered.length < 2) return null; - - // linesP95Added/Removed are shipped-only (computed by aggregateWeekly). - // inFlightLinesP95Added/Removed are the in-flight overlay, P95-filtered separately. - const maxTotal = Math.max( - ...filtered.map(d => - (d.linesP95Added || 0) + (d.linesP95Removed || 0) + - (d.inFlightLinesP95Added || 0) + (d.inFlightLinesP95Removed || 0), - ), - 1, - ); - - const W = 800; - const H = 180; - const padL = 50; - const padR = 12; - const padT = 12; - const padB = 24; - const chartW = W - padL - padR; - const chartH = H - padT - padB; - - const barW = Math.max(4, (chartW / filtered.length) * 0.75); - const barGap = (chartW / filtered.length) - barW; - const xFor = (i: number) => padL + i * (barW + barGap) + barGap / 2; - const yFor = (val: number) => padT + chartH - (val / maxTotal) * chartH; - - const yTicks: number[] = []; - const step = maxTotal <= 1000 ? 200 : maxTotal <= 5000 ? 1000 : maxTotal <= 20000 ? 5000 : maxTotal <= 100000 ? 20000 : 50000; - for (let v = 0; v <= maxTotal; v += step) yTicks.push(v); - - const formatVal = (v: number) => v >= 1000000 ? (v / 1000000).toFixed(1) + 'M' : v >= 1000 ? (v / 1000).toFixed(0) + 'K' : String(v); - const formatWeek = (w: string) => { - const d = new Date(w + 'T00:00:00'); - return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); - }; - const labelStep = Math.max(1, Math.floor(filtered.length / 6)); - const labelIndices = filtered.map((_, i) => i).filter(i => i % labelStep === 0 || i === filtered.length - 1); - - return ( -
-

Lines Changed / Week (outlier commits excluded)

- - {yTicks.map(v => ( - - - {formatVal(v)} - - ))} - {filtered.map((d, i) => { - // Shipped portions (P95-filtered, untouched by overlay) - const shippedA = d.linesP95Added || 0; - const shippedR = d.linesP95Removed || 0; - // In-flight portions (separately P95-filtered over in-flight commits only) - const inFlightA = d.inFlightLinesP95Added || 0; - const inFlightR = d.inFlightLinesP95Removed || 0; - - const totalH = ((shippedA + shippedR + inFlightA + inFlightR) / maxTotal) * chartH; - const shippedAH = (shippedA / maxTotal) * chartH; - const shippedRH = (shippedR / maxTotal) * chartH; - const inFlightAH = (inFlightA / maxTotal) * chartH; - const inFlightRH = (inFlightR / maxTotal) * chartH; - - // Stack from bottom to top: shippedR (red), shippedA (green), inFlightR (amber/red), inFlightA (amber) - const baseY = padT + chartH; - const shippedRemovedY = baseY - shippedRH; - const shippedAddedY = shippedRemovedY - shippedAH; - const inFlightRemovedY = shippedAddedY - inFlightRH; - const inFlightAddedY = inFlightRemovedY - inFlightAH; - - return ( - - {/* added: in-flight (amber, top) + shipped (green, bottom of added stack) */} - {inFlightAH > 0 && ( - - )} - - {/* removed: in-flight (amber muted, top of removed stack) + shipped (red, bottom) */} - {inFlightRH > 0 && ( - - )} - - setHoverIdx(i)} onMouseLeave={() => setHoverIdx(null)} /> - - ); - })} - {hoverIdx !== null && (() => { - const d = filtered[hoverIdx]; - const x = xFor(hoverIdx) + barW / 2; - const a = d.linesP95Added || 0; - const r = d.linesP95Removed || 0; - const lines = [`${formatWeek(d.week)}`, `+${formatVal(a)} added`, `-${formatVal(r)} removed`, `${formatVal(a + r)} total`]; - const textW = 110; - const tooltipX = Math.min(Math.max(x - textW / 2, 2), W - textW - 2); - return ( - - - - {lines.map((line, li) => ( - - {line} - - ))} - - ); - })()} - {labelIndices.map(idx => ( - - {formatWeek(filtered[idx].week)} - - ))} - -
- - Added - - - Removed - -
-
- ); -} - -function PieChart({ entries, total }: { entries: [string, number][]; total: number }) { - const [hoverType, setHoverType] = useState(null); - - // Use a fixed viewBox, SVG scales to chartSize - const vb = 200; - const cx = vb / 2; - const cy = vb / 2; - const r = 96; - const innerR = 58; - - let startAngle = -Math.PI / 2; - const slices = entries.map(([type, count]) => { - const pct = count / total; - const angle = pct * Math.PI * 2; - const endAngle = startAngle + angle; - - const x1 = cx + r * Math.cos(startAngle); - const y1 = cy + r * Math.sin(startAngle); - const x2 = cx + r * Math.cos(endAngle); - const y2 = cy + r * Math.sin(endAngle); - const ix1 = cx + innerR * Math.cos(startAngle); - const iy1 = cy + innerR * Math.sin(startAngle); - const ix2 = cx + innerR * Math.cos(endAngle); - const iy2 = cy + innerR * Math.sin(endAngle); - - const largeArc = angle > Math.PI ? 1 : 0; - const path = [ - `M ${ix1} ${iy1}`, `L ${x1} ${y1}`, - `A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2}`, - `L ${ix2} ${iy2}`, - `A ${innerR} ${innerR} 0 ${largeArc} 0 ${ix1} ${iy1}`, 'Z', - ].join(' '); - - startAngle = endAngle; - return { type, count, pct, path }; - }); - - const hovered = hoverType ? slices.find(s => s.type === hoverType) : null; - - return ( -
- - {slices.map(s => ( - setHoverType(s.type)} - onMouseLeave={() => setHoverType(null)} - className="transition-opacity duration-150 cursor-default" - /> - ))} - {!hovered ? ( - <> - - {total.toLocaleString()} - - - commits - - - ) : ( - <> - - {hovered.count.toLocaleString()} - - - {hovered.type} - - - {Math.round(hovered.pct * 100)}% - - - )} - -
- {entries.map(([type, count]) => ( -
setHoverType(type)} - onMouseLeave={() => setHoverType(null)} - > - - {type} - {count} ({Math.round((count / total) * 100)}%) -
- ))} -
-
- ); -} diff --git a/src/components/charts/commit-type-donut.tsx b/src/components/charts/commit-type-donut.tsx new file mode 100644 index 00000000..82da5fb6 --- /dev/null +++ b/src/components/charts/commit-type-donut.tsx @@ -0,0 +1,89 @@ +'use client'; + +// GLOOK-58 Decision 13: the legend always shows count and %, the centre shows the total at rest +// and the hovered type's figures on hover. Centre text is chrome text; type identity comes from a +// swatch beside it, never from coloured text. Callers pass entries built with typeEntriesFrom(), +// so unknown types are already folded into `other`. +import { useState, type ReactElement } from 'react'; +import { Pie, PieChart } from 'recharts'; +import { ChartContainer } from './chart'; +import { toNum } from './chart-format'; +import { commitTypeColor } from './commit-types'; +import { HatchSwatch, useHatch } from './hatch'; + +function Swatch({ type, size }: { type: string; size: number }): ReactElement { + if (type === 'in_flight') return ; + return