diff --git a/docs/superpowers/plans/2026-06-23-react-component-tree-refactor.md b/docs/superpowers/plans/2026-06-23-react-component-tree-refactor.md
new file mode 100644
index 0000000..90c3ede
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-23-react-component-tree-refactor.md
@@ -0,0 +1,2393 @@
+# React Component Tree Refactor — 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:** Split `react-app/src/App.jsx` into a presentational component tree with a `utils/` library, and add a Vitest + Testing Library UI test suite that covers every user-facing behavior.
+
+**Architecture:** `App.jsx` becomes the orchestrator (state + data fetch + URL sync + derivation of `augmented`/`filtered`/`movers`/`columns`). It composes 7 focused components in `src/components/`. The densest code paths (DataGrid columns, chart geometry) move to pure-function modules in `src/utils/`. Tests render real components with `ThemeProvider` + `jsdom` and drive behavior with `@testing-library/user-event` against a hand-built fixture covering all the interesting data shapes.
+
+**Tech Stack:** Vite 8, React 19.2, MUI 9 + `@mui/x-data-grid` 9. **Tests:** Vitest 2, `@testing-library/react` 16, `@testing-library/jest-dom` 6, `@testing-library/user-event` 14, `jsdom` 25. All test deps go into `devDependencies` in `package.json`.
+
+## Global Constraints
+
+- Zero behavior change. The user-visible app must be identical before and after the refactor.
+- No new dependencies in `dependencies` (only `devDependencies` for tests).
+- `main.jsx`, `index.html`, `Dockerfile`, `docker-compose.yml`, all `*.py` data scripts, and `live_data.json` / `pdf_data.json` are out of scope.
+- `vite.config.js` may be modified ONLY to add a `test:` block for Vitest.
+- `package.json` may be modified to add `devDependencies` and a `test` script.
+- All new files go under `react-app/src/`.
+- Target file size: ≤120 lines per file.
+- Every behavior bullet from the spec's "Verification" step 3 has a corresponding test.
+- Component tests use a `renderWithTheme` helper from `test/setup.js` that wraps the component in `ThemeProvider`. The full App test uses the same helper.
+- Use `data-testid` attributes SPARINGLY — only where Testing Library cannot otherwise get a stable handle (e.g. inside the DataGrid, which uses internal ARIA roles that are awkward to query).
+- Use `vi.fn()` for handler prop assertions. Use `userEvent` (not `fireEvent`) for user interaction.
+- Mock `globalThis.fetch` in tests that need data; do not mock any other module.
+- Commit after each task with a conventional-commits message (`feat:` / `test:` / `chore:` / `refactor:`).
+- After each task, `cd react-app && npm test -- --run` must continue to pass (or the test being added is the one that proves this task).
+
+## File Structure (final)
+
+**New files (15):**
+- `react-app/src/utils/format.js` — `sizeShort`, `pctLabel`, `ptsLabel`, `changeColor`
+- `react-app/src/utils/url.js` — `readFiltersFromUrl`, `writeFiltersToUrl`
+- `react-app/src/utils/movers.js` — `computeMovers`
+- `react-app/src/utils/history.js` — `buildChartGeometry`
+- `react-app/src/utils/columns.jsx` — `buildColumns` (imports `SizeCell`)
+- `react-app/src/components/SizeCell.jsx`
+- `react-app/src/components/PointsHistoryChart.jsx`
+- `react-app/src/components/GraphModal.jsx`
+- `react-app/src/components/MoversPanel.jsx`
+- `react-app/src/components/MoversSection.jsx`
+- `react-app/src/components/FilterBar.jsx`
+- `react-app/src/components/UnitTable.jsx`
+- `react-app/src/test/setup.js` — `renderWithTheme`, vitest setup hooks
+- `react-app/src/test/fixtures/data.json` — minimal but representative dataset
+- `react-app/src/test/utils.test.js` — pure-function smoke tests
+- `react-app/src/test/SizeCell.test.jsx`
+- `react-app/src/test/FilterBar.test.jsx`
+- `react-app/src/test/UnitTable.test.jsx`
+- `react-app/src/test/MoversSection.test.jsx`
+- `react-app/src/test/GraphModal.test.jsx`
+- `react-app/src/test/App.test.jsx`
+
+**Modified files (3):**
+- `react-app/src/App.jsx` — replaced with orchestrator (~90 lines)
+- `react-app/vite.config.js` — add `test:` block
+- `react-app/package.json` — add `test` script + devDependencies
+
+---
+
+## Task 1: Install test framework
+
+**Files:**
+- Modify: `react-app/package.json` (add `test` script + 5 devDependencies)
+- Modify: `react-app/vite.config.js` (add `test:` block)
+
+**Why first:** Every later task needs the test runner working so each step can be verified.
+
+- [ ] **Step 1: Add devDependencies to `package.json`**
+
+In `react-app/package.json`, add a `"test"` script and a `devDependencies` block. The exact edits:
+
+Replace the existing `"scripts"` block:
+```json
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+```
+
+With:
+```json
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview",
+ "test": "vitest"
+ },
+```
+
+Add this block at the bottom of the file (after the closing `}` of the top-level object — replace the top-level `}` with `,\n "devDependencies": { ... }\n}`):
+```json
+ "devDependencies": {
+ "@testing-library/jest-dom": "^6.6.3",
+ "@testing-library/react": "^16.1.0",
+ "@testing-library/user-event": "^14.5.2",
+ "jsdom": "^25.0.1",
+ "vitest": "^2.1.8"
+ }
+```
+
+- [ ] **Step 2: Add Vitest config to `vite.config.js`**
+
+Replace the current contents of `react-app/vite.config.js`:
+```js
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+export default defineConfig({
+ plugins: [react()],
+})
+```
+
+With:
+```js
+///
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+export default defineConfig({
+ plugins: [react()],
+ test: {
+ environment: 'jsdom',
+ globals: true,
+ setupFiles: ['./src/test/setup.js'],
+ css: false,
+ },
+})
+```
+
+- [ ] **Step 3: Create minimal test setup to verify the framework runs**
+
+Create `react-app/src/test/setup.js`:
+```js
+import '@testing-library/jest-dom/vitest'
+```
+
+Create `react-app/src/test/smoke.test.js`:
+```js
+import { describe, it, expect } from 'vitest'
+
+describe('vitest setup', () => {
+ it('runs', () => {
+ expect(1 + 1).toBe(2)
+ })
+})
+```
+
+- [ ] **Step 4: Install dependencies**
+
+Run:
+```bash
+cd react-app && npm install
+```
+
+Expected: `npm install` exits 0; `node_modules` populates with vitest, @testing-library/*, jsdom.
+
+- [ ] **Step 5: Run the smoke test**
+
+Run:
+```bash
+cd react-app && npx vitest run src/test/smoke.test.js
+```
+
+Expected: `1 passed`. If you see an error about `jest-dom` matchers not being defined, the `setup.js` import path is wrong — check it matches exactly.
+
+- [ ] **Step 6: Delete the smoke test (it was only to verify the runner works)**
+
+Run:
+```bash
+rm react-app/src/test/smoke.test.js
+```
+
+- [ ] **Step 7: Commit**
+
+```bash
+cd /home/kaykayyali/projects/wh40k-points-comparator && git add react-app/package.json react-app/vite.config.js react-app/src/test/setup.js && git commit -m "chore: add vitest + testing-library + test config"
+```
+
+---
+
+## Task 2: Build the test fixture
+
+**Files:**
+- Create: `react-app/src/test/fixtures/data.json`
+
+**Why second:** Every later test needs realistic data. The fixture must cover all the interesting shapes the real `data.json` has: multi-size unit, no-history unit, `change_pct === 0`, new unit (`original === null`), removed unit (`new === null`), a few factions.
+
+- [ ] **Step 1: Create the fixture file**
+
+Create `react-app/src/test/fixtures/data.json` with this exact content:
+```json
+{
+ "generated_at": "2026-06-18T00:00:00Z",
+ "versions": [
+ { "date": "2024-12-01", "label": "MFM 1.14" },
+ { "date": "2025-08-20", "label": "MFM 3.2" },
+ { "date": "2026-06-17", "label": "MFM (current)" }
+ ],
+ "factions": ["adepta-sororitas", "space-marines", "necrons"],
+ "faction_names": {
+ "adepta-sororitas": "Adepta Sororitas",
+ "space-marines": "Space Marines",
+ "necrons": "Necrons"
+ },
+ "stats": { "total_rows": 7, "rows_with_both": 5 },
+ "units": [
+ {
+ "faction": "adepta-sororitas",
+ "faction_name": "Adepta Sororitas",
+ "name": "Canoness",
+ "size": "1 model",
+ "original": 60,
+ "new": 60,
+ "tier": null,
+ "change_pct": 0,
+ "change_pts": 0,
+ "default_size": "1 model",
+ "sizes": [
+ {
+ "size": "1 model",
+ "original": 60,
+ "new": 60,
+ "tier": null,
+ "change_pct": 0,
+ "change_pts": 0,
+ "history": [
+ { "date": "2024-12-01", "version": "MFM 1.14", "pts": 60 },
+ { "date": "2026-06-17", "version": "MFM (current)", "pts": 60 }
+ ]
+ }
+ ]
+ },
+ {
+ "faction": "adepta-sororitas",
+ "faction_name": "Adepta Sororitas",
+ "name": "Arco-flagellants",
+ "size": "3 models",
+ "original": 45,
+ "new": 50,
+ "tier": null,
+ "change_pct": 11.11,
+ "change_pts": 5,
+ "default_size": "3 models",
+ "sizes": [
+ {
+ "size": "3 models",
+ "original": 45,
+ "new": 50,
+ "tier": null,
+ "change_pct": 11.11,
+ "change_pts": 5,
+ "history": [
+ { "date": "2024-12-01", "version": "MFM 1.14", "pts": 45 },
+ { "date": "2025-08-20", "version": "MFM 3.2", "pts": 45 },
+ { "date": "2026-06-17", "version": "MFM (current)", "pts": 50 }
+ ]
+ },
+ {
+ "size": "10 models",
+ "original": 140,
+ "new": 140,
+ "tier": null,
+ "change_pct": 0,
+ "change_pts": 0,
+ "history": [
+ { "date": "2024-12-01", "version": "MFM 1.14", "pts": 150 },
+ { "date": "2025-08-20", "version": "MFM 3.2", "pts": 140 },
+ { "date": "2026-06-17", "version": "MFM (current)", "pts": 140 }
+ ]
+ }
+ ]
+ },
+ {
+ "faction": "adepta-sororitas",
+ "faction_name": "Adepta Sororitas",
+ "name": "Palatine",
+ "size": "1 model",
+ "original": 50,
+ "new": 40,
+ "tier": null,
+ "change_pct": -20,
+ "change_pts": -10,
+ "default_size": "1 model",
+ "sizes": [
+ {
+ "size": "1 model",
+ "original": 50,
+ "new": 40,
+ "tier": null,
+ "change_pct": -20,
+ "change_pts": -10,
+ "history": [
+ { "date": "2024-12-01", "version": "MFM 1.14", "pts": 50 },
+ { "date": "2026-06-17", "version": "MFM (current)", "pts": 40 }
+ ]
+ }
+ ]
+ },
+ {
+ "faction": "space-marines",
+ "faction_name": "Space Marines",
+ "name": "Intercessor Squad",
+ "size": "5 models",
+ "original": 100,
+ "new": 95,
+ "tier": null,
+ "change_pct": -5,
+ "change_pts": -5,
+ "default_size": "5 models",
+ "sizes": [
+ {
+ "size": "5 models",
+ "original": 100,
+ "new": 95,
+ "tier": null,
+ "change_pct": -5,
+ "change_pts": -5,
+ "history": [
+ { "date": "2024-12-01", "version": "MFM 1.14", "pts": 100 },
+ { "date": "2026-06-17", "version": "MFM (current)", "pts": 95 }
+ ]
+ }
+ ]
+ },
+ {
+ "faction": "space-marines",
+ "faction_name": "Space Marines",
+ "name": "Redemptor Dreadnought",
+ "size": "1 model",
+ "original": 200,
+ "new": 220,
+ "tier": null,
+ "change_pct": 10,
+ "change_pts": 20,
+ "default_size": "1 model",
+ "sizes": [
+ {
+ "size": "1 model",
+ "original": 200,
+ "new": 220,
+ "tier": null,
+ "change_pct": 10,
+ "change_pts": 20,
+ "history": [
+ { "date": "2024-12-01", "version": "MFM 1.14", "pts": 200 },
+ { "date": "2026-06-17", "version": "MFM (current)", "pts": 220 }
+ ]
+ }
+ ]
+ },
+ {
+ "faction": "necrons",
+ "faction_name": "Necrons",
+ "name": "New Unit X",
+ "size": "5 models",
+ "original": null,
+ "new": 80,
+ "tier": null,
+ "change_pct": null,
+ "change_pts": null,
+ "default_size": "5 models",
+ "sizes": [
+ {
+ "size": "5 models",
+ "original": null,
+ "new": 80,
+ "tier": null,
+ "change_pct": null,
+ "change_pts": null,
+ "history": []
+ }
+ ]
+ },
+ {
+ "faction": "necrons",
+ "faction_name": "Necrons",
+ "name": "Removed Unit Y",
+ "size": "10 models",
+ "original": 120,
+ "new": null,
+ "tier": null,
+ "change_pct": null,
+ "change_pts": null,
+ "default_size": "10 models",
+ "sizes": [
+ {
+ "size": "10 models",
+ "original": 120,
+ "new": null,
+ "tier": null,
+ "change_pct": null,
+ "change_pts": null,
+ "history": []
+ }
+ ]
+ }
+ ]
+}
+```
+
+**Why this set covers everything:**
+- `Canoness` — `change_pct === 0`, 2-point history (tests "no-change" filter + footer of chart)
+- `Arco-flagellants` — multi-size unit, 3-point history on small size, 3-point history on large size (tests size-cell click + chart size dropdown)
+- `Palatine` — large drop (`-20%`), 2-point history (movers)
+- `Intercessor Squad` — small drop (`-5%`), 2-point history
+- `Redemptor Dreadnought` — rise (`+10%`), 2-point history (movers)
+- `New Unit X` — `original === null` (tests "new-only" filter + `—` rendering for original)
+- `Removed Unit Y` — `new === null` (tests "old-only" filter + `—` rendering for new)
+
+- [ ] **Step 2: Verify the fixture is valid JSON**
+
+Run:
+```bash
+cd react-app && python3 -c "import json; json.load(open('src/test/fixtures/data.json')); print('OK')"
+```
+
+Expected: `OK`.
+
+- [ ] **Step 3: Commit**
+
+```bash
+cd /home/kaykayyali/projects/wh40k-points-comparator && git add react-app/src/test/fixtures/data.json && git commit -m "test: add representative data.json fixture for UI tests"
+```
+
+---
+
+## Task 3: `utils/format.js` (pure formatting helpers)
+
+**Files:**
+- Create: `react-app/src/utils/format.js`
+- Create: `react-app/src/test/utils.test.js` (format section)
+
+**Interfaces:**
+- Consumes: nothing (pure)
+- Produces:
+ - `sizeShort(size: string): string` — strips trailing ` model` / ` models`
+ - `pctLabel(pct: number | null): string` — `+12.3%` / `-5.0%` / `—`
+ - `ptsLabel(pts: number | null): string` — `+5` / `-5` / `5` / `—`
+ - `changeColor(val: number | null): string` — `'#f85149'` / `'#3fb950'` / `'text.secondary'`
+
+- [ ] **Step 1: Write the failing test for `sizeShort`**
+
+Add to `react-app/src/test/utils.test.js`:
+```js
+import { describe, it, expect } from 'vitest'
+import { sizeShort } from '../utils/format.js'
+
+describe('sizeShort', () => {
+ it('strips " model" suffix', () => {
+ expect(sizeShort('1 model')).toBe('1')
+ })
+ it('strips " models" suffix', () => {
+ expect(sizeShort('10 models')).toBe('10')
+ })
+ it('leaves other strings unchanged', () => {
+ expect(sizeShort('Squad')).toBe('Squad')
+ })
+})
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run:
+```bash
+cd react-app && npx vitest run src/test/utils.test.js
+```
+
+Expected: FAIL — `Failed to resolve import "../utils/format.js"`.
+
+- [ ] **Step 3: Implement `utils/format.js`**
+
+Create `react-app/src/utils/format.js`:
+```js
+export function sizeShort(size) {
+ return size.replace(/\s*models?$/, '')
+}
+
+export function pctLabel(pct) {
+ if (pct === null || pct === undefined) return '—'
+ const sign = pct > 0 ? '+' : ''
+ return `${sign}${pct.toFixed(1)}%`
+}
+
+export function ptsLabel(pts) {
+ if (pts === null || pts === undefined) return '—'
+ return pts > 0 ? `+${pts}` : `${pts}`
+}
+
+export function changeColor(val) {
+ if (val === null || val === undefined) return 'text.secondary'
+ if (val > 0) return '#f85149'
+ if (val < 0) return '#3fb950'
+ return 'text.secondary'
+}
+```
+
+- [ ] **Step 4: Add tests for the rest of `format.js`**
+
+Append to `react-app/src/test/utils.test.js`:
+```js
+import { pctLabel, ptsLabel, changeColor } from '../utils/format.js'
+
+describe('pctLabel', () => {
+ it('prefixes positive values with +', () => {
+ expect(pctLabel(12.34)).toBe('+12.3%')
+ })
+ it('renders negative values without prefix', () => {
+ expect(pctLabel(-5)).toBe('-5.0%')
+ })
+ it('renders zero without prefix', () => {
+ expect(pctLabel(0)).toBe('0.0%')
+ })
+ it('renders em-dash for null', () => {
+ expect(pctLabel(null)).toBe('—')
+ })
+ it('renders em-dash for undefined', () => {
+ expect(pctLabel(undefined)).toBe('—')
+ })
+})
+
+describe('ptsLabel', () => {
+ it('prefixes positive values with +', () => {
+ expect(ptsLabel(10)).toBe('+10')
+ })
+ it('renders negative values as bare -N', () => {
+ expect(ptsLabel(-5)).toBe('-5')
+ })
+ it('renders zero without prefix', () => {
+ expect(ptsLabel(0)).toBe('0')
+ })
+ it('renders em-dash for null', () => {
+ expect(ptsLabel(null)).toBe('—')
+ })
+})
+
+describe('changeColor', () => {
+ it('returns red for positive', () => {
+ expect(changeColor(1)).toBe('#f85149')
+ })
+ it('returns green for negative', () => {
+ expect(changeColor(-1)).toBe('#3fb950')
+ })
+ it('returns text.secondary for zero', () => {
+ expect(changeColor(0)).toBe('text.secondary')
+ })
+ it('returns text.secondary for null', () => {
+ expect(changeColor(null)).toBe('text.secondary')
+ })
+})
+```
+
+- [ ] **Step 5: Run all `format` tests**
+
+Run:
+```bash
+cd react-app && npx vitest run src/test/utils.test.js
+```
+
+Expected: all 13 tests pass.
+
+- [ ] **Step 6: Commit**
+
+```bash
+cd /home/kaykayyali/projects/wh40k-points-comparator && git add react-app/src/utils/format.js react-app/src/test/utils.test.js && git commit -m "feat(utils): add format helpers (sizeShort, pctLabel, ptsLabel, changeColor)"
+```
+
+---
+
+## Task 4: `utils/url.js` (URL ⇄ filter state)
+
+**Files:**
+- Create: `react-app/src/utils/url.js`
+- Modify: `react-app/src/test/utils.test.js` (add url section)
+
+**Interfaces:**
+- Consumes: `window.location` (mutates it via `history.replaceState` in the writer)
+- Produces:
+ - `readFiltersFromUrl(): { q: string, faction: string, dir: string }`
+ - `writeFiltersToUrl({ q, faction, dir }): void` — clears the query string when all three are empty
+
+- [ ] **Step 1: Write the failing test for `readFiltersFromUrl`**
+
+Append to `react-app/src/test/utils.test.js`:
+```js
+import { readFiltersFromUrl, writeFiltersToUrl } from '../utils/url.js'
+
+describe('readFiltersFromUrl', () => {
+ it('returns defaults when search is empty', () => {
+ window.history.replaceState(null, '', '/?')
+ expect(readFiltersFromUrl()).toEqual({ q: '', faction: '', dir: '' })
+ })
+ it('reads q, faction, dir from query string', () => {
+ window.history.replaceState(null, '', '/?q=foo&faction=space-marines&dir=up')
+ expect(readFiltersFromUrl()).toEqual({ q: 'foo', faction: 'space-marines', dir: 'up' })
+ })
+ it('returns empty strings for missing keys', () => {
+ window.history.replaceState(null, '', '/?q=foo')
+ expect(readFiltersFromUrl()).toEqual({ q: 'foo', faction: '', dir: '' })
+ })
+})
+
+describe('writeFiltersToUrl', () => {
+ it('writes all three params', () => {
+ writeFiltersToUrl({ q: 'foo', faction: 'space-marines', dir: 'up' })
+ expect(window.location.search).toBe('?q=foo&faction=space-marines&dir=up')
+ })
+ it('clears the query string when all are empty', () => {
+ writeFiltersToUrl({ q: '', faction: '', dir: '' })
+ expect(window.location.search).toBe('')
+ })
+ it('writes only the present keys (others stay empty)', () => {
+ writeFiltersToUrl({ q: 'foo', faction: '', dir: '' })
+ expect(window.location.search).toBe('?q=foo')
+ })
+})
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run:
+```bash
+cd react-app && npx vitest run src/test/utils.test.js
+```
+
+Expected: FAIL — `Failed to resolve import "../utils/url.js"`.
+
+- [ ] **Step 3: Implement `utils/url.js`**
+
+Create `react-app/src/utils/url.js`:
+```js
+export function readFiltersFromUrl() {
+ const params = new URLSearchParams(window.location.search)
+ return {
+ q: params.get('q') || '',
+ faction: params.get('faction') || '',
+ dir: params.get('dir') || '',
+ }
+}
+
+export function writeFiltersToUrl({ q, faction, dir }) {
+ const params = new URLSearchParams()
+ if (q) params.set('q', q)
+ if (faction) params.set('faction', faction)
+ if (dir) params.set('dir', dir)
+ const qs = params.toString()
+ const newUrl = qs ? `?${qs}` : window.location.pathname
+ window.history.replaceState(null, '', newUrl)
+}
+```
+
+- [ ] **Step 4: Run all tests**
+
+Run:
+```bash
+cd react-app && npx vitest run src/test/utils.test.js
+```
+
+Expected: all 19 tests pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd /home/kaykayyali/projects/wh40k-points-comparator && git add react-app/src/utils/url.js react-app/src/test/utils.test.js && git commit -m "feat(utils): add URL read/write helpers for filter state"
+```
+
+---
+
+## Task 5: `utils/movers.js` (top-N by direction)
+
+**Files:**
+- Create: `react-app/src/utils/movers.js`
+- Modify: `react-app/src/test/utils.test.js` (add movers section)
+
+**Interfaces:**
+- Consumes: array of `Unit` (each has `change_pct: number | null`)
+- Produces:
+ - `computeMovers(filtered: Unit[]): { drops: Unit[], rises: Unit[] }` — at most 5 each, sorted desc by magnitude, excludes `change_pct === null`
+
+- [ ] **Step 1: Write the failing test for `computeMovers`**
+
+Append to `react-app/src/test/utils.test.js`:
+```js
+import { computeMovers } from '../utils/movers.js'
+
+const mk = (name, change_pct) => ({ name, change_pct })
+
+describe('computeMovers', () => {
+ it('returns empty arrays for empty input', () => {
+ expect(computeMovers([])).toEqual({ drops: [], rises: [] })
+ })
+ it('returns top 5 drops (largest negative change_pct first)', () => {
+ const input = [
+ mk('A', -1), mk('B', -50), mk('C', -20), mk('D', -5),
+ mk('E', -10), mk('F', -30), mk('G', -2),
+ ]
+ expect(computeMovers(input).drops.map(u => u.name)).toEqual(['B', 'F', 'C', 'E', 'D'])
+ })
+ it('returns top 5 rises (largest positive change_pct first)', () => {
+ const input = [
+ mk('A', 1), mk('B', 50), mk('C', 20), mk('D', 5),
+ mk('E', 10), mk('F', 30), mk('G', 2),
+ ]
+ expect(computeMovers(input).rises.map(u => u.name)).toEqual(['B', 'F', 'C', 'E', 'D'])
+ })
+ it('excludes units with change_pct === null from both lists', () => {
+ const input = [mk('A', -10), mk('B', null), mk('C', 10)]
+ const { drops, rises } = computeMovers(input)
+ expect(drops.map(u => u.name)).toEqual(['A'])
+ expect(rises.map(u => u.name)).toEqual(['C'])
+ })
+ it('excludes units with change_pct === 0 from both lists', () => {
+ const input = [mk('A', -10), mk('B', 0), mk('C', 10)]
+ const { drops, rises } = computeMovers(input)
+ expect(drops.map(u => u.name)).toEqual(['A'])
+ expect(rises.map(u => u.name)).toEqual(['C'])
+ })
+})
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run:
+```bash
+cd react-app && npx vitest run src/test/utils.test.js
+```
+
+Expected: FAIL — `Failed to resolve import`.
+
+- [ ] **Step 3: Implement `utils/movers.js`**
+
+Create `react-app/src/utils/movers.js`:
+```js
+export function computeMovers(filtered) {
+ if (!filtered || filtered.length === 0) return { drops: [], rises: [] }
+ const drops = filtered
+ .filter(u => u.change_pct !== null && u.change_pct < 0)
+ .sort((a, b) => a.change_pct - b.change_pct)
+ .slice(0, 5)
+ const rises = filtered
+ .filter(u => u.change_pct !== null && u.change_pct > 0)
+ .sort((a, b) => b.change_pct - a.change_pct)
+ .slice(0, 5)
+ return { drops, rises }
+}
+```
+
+- [ ] **Step 4: Run all tests**
+
+Run:
+```bash
+cd react-app && npx vitest run src/test/utils.test.js
+```
+
+Expected: all 24 tests pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd /home/kaykayyali/projects/wh40k-points-comparator && git add react-app/src/utils/movers.js react-app/src/test/utils.test.js && git commit -m "feat(utils): add computeMovers (top-5 drops + rises)"
+```
+
+---
+
+## Task 6: `utils/history.js` (chart scale math)
+
+**Files:**
+- Create: `react-app/src/utils/history.js`
+- Modify: `react-app/src/test/utils.test.js` (add history section)
+
+**Interfaces:**
+- Consumes: `history: HistoryPoint[]` (each `{ pts: number, date, version }`) and `dims: { W, H, padL, padR, padT, padB }`
+- Produces:
+ - `buildChartGeometry(history, dims): { xFor, yFor, yTicks, linePath, areaPath, chartW, chartH }`
+ - `xFor(i: number) => number`, `yFor(v: number) => number`
+ - `yTicks: Array<{ v: number, y: number }>`
+ - `linePath: string` (empty if `history.length === 0`)
+ - `areaPath: string` (empty if `history.length < 2`)
+ - `chartW`, `chartH` — inner dimensions
+
+- [ ] **Step 1: Write the failing test for `buildChartGeometry`**
+
+Append to `react-app/src/test/utils.test.js`:
+```js
+import { buildChartGeometry } from '../utils/history.js'
+
+const DIMS = { W: 640, H: 360, padL: 70, padR: 24, padT: 24, padB: 48 }
+
+describe('buildChartGeometry', () => {
+ it('returns empty linePath for empty history', () => {
+ const g = buildChartGeometry([], DIMS)
+ expect(g.linePath).toBe('')
+ expect(g.areaPath).toBe('')
+ expect(g.yTicks).toEqual([])
+ })
+ it('returns empty areaPath for single-point history', () => {
+ const g = buildChartGeometry([{ pts: 50, date: '2026-01-01', version: 'v1' }], DIMS)
+ expect(g.linePath).not.toBe('')
+ expect(g.areaPath).toBe('')
+ })
+ it('produces a multi-segment path for 3+ points', () => {
+ const g = buildChartGeometry([
+ { pts: 50, date: '2024-01-01', version: 'v1' },
+ { pts: 60, date: '2025-01-01', version: 'v2' },
+ { pts: 40, date: '2026-01-01', version: 'v3' },
+ ], DIMS)
+ expect(g.linePath.split('L').length).toBe(3) // 1 M + 2 L
+ expect(g.areaPath).not.toBe('')
+ })
+ it('produces y-axis ticks spanning the data range', () => {
+ const g = buildChartGeometry([
+ { pts: 10, date: '2024-01-01', version: 'v1' },
+ { pts: 100, date: '2026-01-01', version: 'v2' },
+ ], DIMS)
+ expect(g.yTicks.length).toBeGreaterThan(1)
+ const values = g.yTicks.map(t => t.v)
+ expect(Math.min(...values)).toBeLessThanOrEqual(10)
+ expect(Math.max(...values)).toBeGreaterThanOrEqual(100)
+ })
+ it('chartW and chartH are W minus horizontal/vertical padding', () => {
+ const g = buildChartGeometry([], DIMS)
+ expect(g.chartW).toBe(DIMS.W - DIMS.padL - DIMS.padR)
+ expect(g.chartH).toBe(DIMS.H - DIMS.padT - DIMS.padB)
+ })
+})
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run:
+```bash
+cd react-app && npx vitest run src/test/utils.test.js
+```
+
+Expected: FAIL — import error.
+
+- [ ] **Step 3: Implement `utils/history.js`**
+
+This is a direct extraction of the math from the current `GraphModal` (App.jsx lines 92–119). Create `react-app/src/utils/history.js`:
+```js
+export function buildChartGeometry(history, { W, H, padL, padR, padT, padB }) {
+ const chartW = W - padL - padR
+ const chartH = H - padT - padB
+
+ const pts = history.map(h => h.pts)
+ const minPts = Math.min(...(pts.length ? pts : [0]), 0)
+ const maxPts = Math.max(...(pts.length ? pts : [1]), 1)
+ const ptsRange = maxPts - minPts || 1
+ const padY = ptsRange * 0.15
+ const yMin = Math.max(0, minPts - padY)
+ const yMax = maxPts + padY
+ const yRange = yMax - yMin || 1
+
+ const n = history.length
+ const xFor = (i) => n <= 1 ? chartW / 2 + padL : padL + (i / (n - 1)) * chartW
+ const yFor = (v) => padT + chartH - ((v - yMin) / yRange) * chartH
+
+ const linePath = history.map((h, i) => `${i === 0 ? 'M' : 'L'} ${xFor(i)} ${yFor(h.pts)}`).join(' ')
+ const areaPath = history.length > 1
+ ? `${linePath} L ${xFor(n - 1)} ${padT + chartH} L ${xFor(0)} ${padT + chartH} Z`
+ : ''
+
+ const yTicks = []
+ const tickCount = Math.min(4, Math.ceil(yRange / 10))
+ for (let i = 0; i <= tickCount; i++) {
+ const v = yMin + (yRange * i / tickCount)
+ yTicks.push({ v: Math.round(v), y: yFor(v) })
+ }
+
+ return { xFor, yFor, yTicks, linePath, areaPath, chartW, chartH }
+}
+```
+
+- [ ] **Step 4: Run all tests**
+
+Run:
+```bash
+cd react-app && npx vitest run src/test/utils.test.js
+```
+
+Expected: all 29 tests pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd /home/kaykayyali/projects/wh40k-points-comparator && git add react-app/src/utils/history.js react-app/src/test/utils.test.js && git commit -m "feat(utils): add buildChartGeometry (pure SVG scale math)"
+```
+
+---
+
+## Task 7: `components/SizeCell.jsx`
+
+**Files:**
+- Create: `react-app/src/components/SizeCell.jsx`
+- Create: `react-app/src/test/SizeCell.test.jsx`
+
+**Interfaces:**
+- Consumes: `{ row: { size: string, sizes?: Array<{ size: string }> }, onSelect: (row, sizeLabel) => void }`
+- Produces: a single-size `` when `row.sizes.length <= 1`; a `