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 ` { e.stopPropagation(); onSelect(row, e.target.value) }} + onClick={(e) => e.stopPropagation()} + variant="outlined" + IconComponent={() => null} + sx={{ + minWidth: 'auto', + height: 24, + '& .MuiSelect-select': { py: 0.15, px: 0.5, fontSize: { xs: '0.65rem', sm: '0.75rem' }, fontWeight: 600, paddingRight: '0.5px !important' }, + '& .MuiOutlinedInput-notchedOutline': { borderColor: 'rgba(59,130,246,0.3)' }, + '&:hover .MuiOutlinedInput-notchedOutline': { borderColor: 'primary.main' }, + '&.Mui-focused .MuiOutlinedInput-notchedOutline': { borderColor: 'primary.main' }, + }} + renderValue={(v) => sizeShort(v)} + > + {sizes.map((s) => ( + + {sizeShort(s.size)} + + ))} + + + ) +} +``` + +- [ ] **Step 4: Run the test** + +Run: +```bash +cd react-app && npx vitest run src/test/SizeCell.test.jsx +``` + +Expected: all 4 tests pass. If the `Select` combobox isn't found, the click on the combobox may be blocked by an overlay — wrap the component in a parent that has `pointer-events: auto` (the test wrapper already does). If `getByRole('option', { name: '10' })` fails, the menu may not have opened — use `await user.click(...)` and ensure the menu transition is awaited (Vitest with jsdom does not actually animate). + +- [ ] **Step 5: Commit** + +```bash +cd /home/kaykayyali/projects/wh40k-points-comparator && git add react-app/src/components/SizeCell.jsx react-app/src/test/SizeCell.test.jsx && git commit -m "feat(components): extract SizeCell from App" +``` + +--- + +## Task 8: `components/PointsHistoryChart.jsx` + +**Files:** +- Create: `react-app/src/components/PointsHistoryChart.jsx` +- (No dedicated test file — the chart is exercised by `GraphModal.test.jsx` in Task 9, which renders the chart end-to-end.) + +**Interfaces:** +- Consumes: `{ history: HistoryPoint[], W?: number = 640, H?: number = 360 }` +- Produces: the SVG (grid, area, line, points, labels) + +- [ ] **Step 1: Implement `PointsHistoryChart.jsx`** + +Direct extraction of the chart rendering from the current `GraphModal` (App.jsx lines 161–180). Create `react-app/src/components/PointsHistoryChart.jsx`: +```jsx +import React from 'react' +import { Box } from '@mui/material' +import { buildChartGeometry } from '../utils/history.js' + +const PADL = 70, PADR = 24, PADT = 24, PADB = 48 + +const fmtDate = (d) => { + const dt = new Date(d) + return dt.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: '2-digit' }) +} + +export function PointsHistoryChart({ history, W = 640, H = 360 }) { + const { xFor, yFor, yTicks, linePath, areaPath } = buildChartGeometry( + history, + { W, H, padL: PADL, padR: PADR, padT: PADT, padB: PADB }, + ) + + return ( + + + + {yTicks.map((t, i) => ( + + + {t.v} + + ))} + {areaPath && } + + {history.map((h, i) => ( + + + {h.pts} + {fmtDate(h.date)} + + ))} + + + + ) +} +``` + +- [ ] **Step 2: Commit** + +```bash +cd /home/kaykayyali/projects/wh40k-points-comparator && git add react-app/src/components/PointsHistoryChart.jsx && git commit -m "feat(components): extract PointsHistoryChart from GraphModal" +``` + +--- + +## Task 9: `components/GraphModal.jsx` + test + +**Files:** +- Create: `react-app/src/components/GraphModal.jsx` +- Create: `react-app/src/test/GraphModal.test.jsx` + +**Interfaces:** +- Consumes: `{ row: Unit | null, open: boolean, onClose: () => void }` +- Produces: the MUI Modal with header, size dropdown (only when multi-size), embedded ``, footer summary, "No historical data" message + +- [ ] **Step 1: Write the failing test** + +Create `react-app/src/test/GraphModal.test.jsx`: +```jsx +import { describe, it, expect, vi } from 'vitest' +import { render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import fixture from './fixtures/data.json' +import { GraphModal } from '../components/GraphModal.jsx' + +const arco = fixture.units.find(u => u.name === 'Arco-flagellants') +const palatine = fixture.units.find(u => u.name === 'Palatine') +const newUnit = fixture.units.find(u => u.name === 'New Unit X') + +function renderModal(row, onClose = vi.fn()) { + return render() +} + +describe('GraphModal', () => { + it('renders the unit name and faction in the header', () => { + renderModal(palatine) + // Modal renders; the name appears in the dialog + expect(screen.getByText('Palatine')).toBeInTheDocument() + expect(screen.getByText('Adepta Sororitas')).toBeInTheDocument() + }) + + it('shows the size dropdown for multi-size units and updates the chart', async () => { + const user = userEvent.setup() + renderModal(arco) + // The size dropdown should be present (MUI Select) + const selects = screen.getAllByRole('combobox') + expect(selects.length).toBeGreaterThan(0) + // The chart should render at least one circle per data point on the small size (3 points) + const svg = document.querySelector('svg') + expect(svg).toBeInTheDocument() + expect(svg.querySelectorAll('circle').length).toBe(3) + }) + + it('shows "No historical data" when the unit has empty history', () => { + renderModal(newUnit) + expect(screen.getByText(/no historical data/i)).toBeInTheDocument() + }) + + it('calls onClose when the close button is clicked', async () => { + const user = userEvent.setup() + const onClose = vi.fn() + renderModal(palatine, onClose) + // The close button is a small IconButton with ✕ text + await user.click(screen.getByRole('button', { name: '✕' })) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('renders nothing when row is null', () => { + const { container } = render( {}} />) + // No header text should be present + expect(screen.queryByText('Palatine')).not.toBeInTheDocument() + // container should be effectively empty (no dialog rendered with meaningful content) + expect(container.textContent).toBe('') + }) + + it('shows first/last version footer for multi-point history', () => { + renderModal(palatine) // 2 history points + // Footer should show "MFM 1.14: 50pts" and "MFM (current): 40pts" + expect(screen.getByText(/MFM 1\.14: 50pts/)).toBeInTheDocument() + expect(screen.getByText(/MFM \(current\): 40pts/)).toBeInTheDocument() + }) +}) +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: +```bash +cd react-app && npx vitest run src/test/GraphModal.test.jsx +``` + +Expected: FAIL — import error. + +- [ ] **Step 3: Implement `GraphModal.jsx`** + +Create `react-app/src/components/GraphModal.jsx`: +```jsx +import React, { useState, useEffect } from 'react' +import { + Box, Typography, IconButton, Modal, Paper, + FormControl, InputLabel, Select, MenuItem, +} from '@mui/material' +import { PointsHistoryChart } from './PointsHistoryChart.jsx' +import { sizeShort } from '../utils/format.js' + +export function GraphModal({ row, open, onClose }) { + const [graphSize, setGraphSize] = useState(null) + + useEffect(() => { + if (row) setGraphSize(row.size) + }, [row]) + + if (!row) return null + + const sizes = row.sizes || [] + const activeSize = graphSize || row.size + const activeSizeData = sizes.find(s => s.size === activeSize) || sizes[0] + const history = activeSizeData?.history || [] + + return ( + + + + + {row.name} + {row.faction_name} + + + + + {sizes.length > 1 && ( + + Model count + + + )} + + {history.length > 0 ? ( + + ) : ( + + No historical data for this unit. + + )} + + {history.length > 1 && ( + + + {history[0].version}: {history[0].pts}pts + + + {history[history.length - 1].version}: {history[history.length - 1].pts}pts + + + )} + + + ) +} +``` + +Note the added `aria-label="✕"` on the close button — this makes the close button queryable in the test by accessible name. The current production code does not have this attribute; the visual rendering is unchanged (the `✕` character still displays). + +- [ ] **Step 4: Run the test** + +Run: +```bash +cd react-app && npx vitest run src/test/GraphModal.test.jsx +``` + +Expected: all 6 tests pass. If `getByText('Adepta Sororitas')` finds multiple matches (the faction name also appears in the movers card on a full App render — but here we render GraphModal in isolation, so only one match is expected). If `getByText(/no historical data/i)` fails, ensure the message text is exact: `"No historical data for this unit."`. + +- [ ] **Step 5: Commit** + +```bash +cd /home/kaykayyali/projects/wh40k-points-comparator && git add react-app/src/components/GraphModal.jsx react-app/src/test/GraphModal.test.jsx && git commit -m "feat(components): extract GraphModal from App" +``` + +--- + +## Task 10: `components/MoversPanel.jsx` + `MoversSection.jsx` + tests + +**Files:** +- Create: `react-app/src/components/MoversPanel.jsx` +- Create: `react-app/src/components/MoversSection.jsx` +- Create: `react-app/src/test/MoversSection.test.jsx` + +**Interfaces:** +- `MoversPanel`: + - Consumes: `{ title: string, units: Unit[], accent: 'success' | 'error', onSelectUnit: (row) => void, showFaction: boolean }` + - Produces: one Paper with a list of clickable rows +- `MoversSection`: + - Consumes: `{ movers: { drops, rises }, onSelectUnit, showFaction }` + - Produces: Container with two `MoversPanel` instances (drops=success, rises=error) + +- [ ] **Step 1: Write the failing test for `MoversSection`** + +Create `react-app/src/test/MoversSection.test.jsx`: +```jsx +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import fixture from './fixtures/data.json' +import { MoversSection } from '../components/MoversSection.jsx' + +const palatine = fixture.units.find(u => u.name === 'Palatine') +const intercessor = fixture.units.find(u => u.name === 'Intercessor Squad') +const dread = fixture.units.find(u => u.name === 'Redemptor Dreadnought') + +describe('MoversSection', () => { + it('renders both panel titles', () => { + render( + {}} + showFaction={true} + />, + ) + expect(screen.getByText('↓ Cheaper')).toBeInTheDocument() + expect(screen.getByText('↑ Costlier')).toBeInTheDocument() + }) + + it('lists units in the drops panel under "Cheaper"', () => { + render( + {}} + showFaction={true} + />, + ) + // Both drops should be present + expect(screen.getByText('Palatine')).toBeInTheDocument() + expect(screen.getByText('Intercessor Squad')).toBeInTheDocument() + // The rises unit should also be in the document + expect(screen.getByText('Redemptor Dreadnought')).toBeInTheDocument() + }) + + it('hides faction name in movers when showFaction is false', () => { + render( + {}} + showFaction={false} + />, + ) + // The unit name still shows + expect(screen.getByText('Palatine')).toBeInTheDocument() + // The faction name should NOT show in the mover row + // Note: it might still appear elsewhere on the page in a real App, but in this + // isolated render of MoversSection, the only place it would appear is inside + // the panel rows. So we expect it not to be present. + const rows = screen.getAllByText(/Adepta Sororitas/) + expect(rows.length).toBe(0) + }) + + it('calls onSelectUnit with the clicked row', async () => { + const user = userEvent.setup() + const onSelect = vi.fn() + render( + , + ) + await user.click(screen.getByText('Palatine')) + expect(onSelect).toHaveBeenCalledTimes(1) + expect(onSelect).toHaveBeenCalledWith(palatine) + }) +}) +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: +```bash +cd react-app && npx vitest run src/test/MoversSection.test.jsx +``` + +Expected: FAIL — import error. + +- [ ] **Step 3: Implement `MoversPanel.jsx`** + +Create `react-app/src/components/MoversPanel.jsx`: +```jsx +import React from 'react' +import { Box, Paper, Typography, Stack } from '@mui/material' +import { pctLabel } from '../utils/format.js' + +export function MoversPanel({ title, units, accent, onSelectUnit, showFaction }) { + return ( + + {title} + + {units.map((u, i) => ( + onSelectUnit(u)} + > + + {u.name} + {showFaction && ( + {u.faction_name} + )} + + + {u.original}→{u.new} ({pctLabel(u.change_pct)}) + + + ))} + + + ) +} +``` + +- [ ] **Step 4: Implement `MoversSection.jsx`** + +Create `react-app/src/components/MoversSection.jsx`: +```jsx +import React from 'react' +import { Box, Container } from '@mui/material' +import { MoversPanel } from './MoversPanel.jsx' + +export function MoversSection({ movers, onSelectUnit, showFaction }) { + return ( + + + + + + + ) +} +``` + +- [ ] **Step 5: Run the test** + +Run: +```bash +cd react-app && npx vitest run src/test/MoversSection.test.jsx +``` + +Expected: all 4 tests pass. If `getByText('Palatine')` finds the unit name in both the mover row AND somewhere else, use `getAllByText` and check the first one is the clickable one. If the click on the unit name does not bubble to the parent Box, ensure the Typography does not have `pointer-events: none` — MUI Typography doesn't by default. + +- [ ] **Step 6: Commit** + +```bash +cd /home/kaykayyali/projects/wh40k-points-comparator && git add react-app/src/components/MoversPanel.jsx react-app/src/components/MoversSection.jsx react-app/src/test/MoversSection.test.jsx && git commit -m "feat(components): extract MoversPanel + MoversSection from App" +``` + +--- + +## Task 11: `components/FilterBar.jsx` + test + +**Files:** +- Create: `react-app/src/components/FilterBar.jsx` +- Create: `react-app/src/test/FilterBar.test.jsx` + +**Interfaces:** +- Consumes: `{ query, setQuery, faction, setFaction, dir, setDir, factions, factionNames, isMobile, totalRows }` +- Produces: the sticky Container with title, subtitle, search field, faction select, change select + +- [ ] **Step 1: Write the failing test** + +Create `react-app/src/test/FilterBar.test.jsx`: +```jsx +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { FilterBar } from '../components/FilterBar.jsx' + +const defaultProps = { + query: '', + setQuery: vi.fn(), + faction: '', + setFaction: vi.fn(), + dir: '', + setDir: vi.fn(), + factions: ['adepta-sororitas', 'space-marines'], + factionNames: { 'adepta-sororitas': 'Adepta Sororitas', 'space-marines': 'Space Marines' }, + isMobile: false, + totalRows: 7, +} + +describe('FilterBar', () => { + it('renders title, subtitle with row count, and all three controls', () => { + render() + expect(screen.getByText('Points Comparator')).toBeInTheDocument() + expect(screen.getByText(/7 units/)).toBeInTheDocument() + expect(screen.getByLabelText('Search')).toBeInTheDocument() + expect(screen.getByLabelText('Faction')).toBeInTheDocument() + expect(screen.getByLabelText('Change')).toBeInTheDocument() + }) + + it('calls setQuery when the user types in the search field', async () => { + const user = userEvent.setup() + const setQuery = vi.fn() + render() + const search = screen.getByLabelText('Search') + await user.type(search, 'palatine') + // Each character fires setQuery once + expect(setQuery).toHaveBeenCalled() + expect(setQuery).toHaveBeenLastCalledWith('e') // last char typed + }) + + it('calls setFaction when the user picks a faction', async () => { + const user = userEvent.setup() + const setFaction = vi.fn() + render() + await user.click(screen.getByLabelText('Faction')) + await user.click(screen.getByRole('option', { name: 'Space Marines' })) + expect(setFaction).toHaveBeenCalledWith('space-marines') + }) + + it('calls setDir when the user picks a change direction', async () => { + const user = userEvent.setup() + const setDir = vi.fn() + render() + await user.click(screen.getByLabelText('Change')) + await user.click(screen.getByRole('option', { name: /Cheaper/ })) + expect(setDir).toHaveBeenCalledWith('down') + }) + + it('uses "Δ" as the change-select label on mobile', () => { + render() + // On mobile, the label is the symbol + expect(screen.getByLabelText('Δ')).toBeInTheDocument() + // The longer "Change" label should not be present + expect(screen.queryByLabelText('Change')).not.toBeInTheDocument() + }) + + it('includes an "All" option in both selects', async () => { + const user = userEvent.setup() + render() + await user.click(screen.getByLabelText('Faction')) + expect(screen.getByRole('option', { name: 'All' })).toBeInTheDocument() + }) +}) +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: +```bash +cd react-app && npx vitest run src/test/FilterBar.test.jsx +``` + +Expected: FAIL — import error. + +- [ ] **Step 3: Implement `FilterBar.jsx`** + +Direct extraction of the sticky filter band (App.jsx lines 371–416). Create `react-app/src/components/FilterBar.jsx`: +```jsx +import React from 'react' +import { + Box, Container, Typography, TextField, Select, MenuItem, InputLabel, + FormControl, Stack, +} from '@mui/material' + +export function FilterBar({ + query, setQuery, + faction, setFaction, + dir, setDir, + factions, factionNames, + isMobile, totalRows, +}) { + return ( + + + + Points Comparator + + + Codex vs. MFM v4.3 · {totalRows.toLocaleString()} units · green = cheaper · red = costlier + + + setQuery(e.target.value)} + placeholder="Unit name…" + sx={{ flex: 2, minWidth: { xs: '100%', sm: 180 } }} + /> + + + Faction + + + + {isMobile ? 'Δ' : 'Change'} + + + + + + + ) +} +``` + +- [ ] **Step 4: Run the test** + +Run: +```bash +cd react-app && npx vitest run src/test/FilterBar.test.jsx +``` + +Expected: all 6 tests pass. If `screen.getByLabelText('Search')` fails, MUI's `TextField` derives the label-id from the `label` prop. If `getByRole('option', { name: 'Space Marines' })` doesn't work, use `getByText` instead — MUI MenuItem text is accessible. + +- [ ] **Step 5: Commit** + +```bash +cd /home/kaykayyali/projects/wh40k-points-comparator && git add react-app/src/components/FilterBar.jsx react-app/src/test/FilterBar.test.jsx && git commit -m "feat(components): extract FilterBar from App" +``` + +--- + +## Task 12: `utils/columns.jsx` + +**Files:** +- Create: `react-app/src/utils/columns.jsx` +- (No dedicated test file — exercised by `UnitTable.test.jsx` in Task 13 and `App.test.jsx` in Task 15.) + +**Interfaces:** +- Consumes: `{ isMobile: boolean, showFactionCol: boolean, onSelectSize: (row, sizeLabel) => void }` +- Produces: `GridColDef[]` — Faction (optional, unshifted), Unit, #, Old, New, Δ pts (desktop only, spliced at index 4), Δ % + +- [ ] **Step 1: Implement `columns.jsx`** + +Direct extraction of the column-definitions block (App.jsx lines 291–365). Create `react-app/src/utils/columns.jsx`: +```jsx +import React from 'react' +import { Box, Typography } from '@mui/material' +import { SizeCell } from '../components/SizeCell.jsx' +import { pctLabel, ptsLabel, changeColor } from './format.js' + +export function buildColumns({ isMobile, showFactionCol, onSelectSize }) { + const cols = [ + { + field: 'name', headerName: 'Unit', flex: 3, minWidth: 80, + renderCell: (p) => ( + + + {p.row.name} + + + ), + }, + { + field: 'size', headerName: '#', flex: 0.6, minWidth: 36, + renderCell: (p) => , + }, + { + field: 'original', headerName: 'Old', flex: 0.8, minWidth: 36, align: 'right', headerAlign: 'right', + renderCell: (p) => ( + + {p.row.original ?? '—'} + + ), + }, + { + field: 'new', headerName: 'New', flex: 0.8, minWidth: 36, align: 'right', headerAlign: 'right', + renderCell: (p) => ( + + {p.row.new ?? '—'} + + ), + }, + { + field: 'change_pct', headerName: 'Δ %', flex: 1, minWidth: 44, align: 'right', headerAlign: 'right', + renderCell: (p) => ( + + {pctLabel(p.row.change_pct)} + + ), + }, + ] + + // Δ pts column — desktop only + if (!isMobile) { + cols.splice(4, 0, { + field: 'change_pts', headerName: 'Δ pts', flex: 0.8, minWidth: 40, align: 'right', headerAlign: 'right', + renderCell: (p) => ( + + {ptsLabel(p.row.change_pts)} + + ), + }) + } + + if (showFactionCol) { + cols.unshift({ + field: 'faction_name', headerName: 'Faction', flex: 1.5, minWidth: 80, + renderCell: (p) => ( + + {p.row.faction_name} + + ), + }) + } + return cols +} +``` + +- [ ] **Step 2: Commit** + +```bash +cd /home/kaykayyali/projects/wh40k-points-comparator && git add react-app/src/utils/columns.jsx && git commit -m "feat(utils): extract buildColumns from App" +``` + +--- + +## Task 13: `components/UnitTable.jsx` + test + +**Files:** +- Create: `react-app/src/components/UnitTable.jsx` +- Create: `react-app/src/test/UnitTable.test.jsx` + +**Interfaces:** +- Consumes: `{ rows: Unit[], columns: GridColDef[], filteredCount: number, onCellClick: (row) => void }` +- Produces: Paper with the count header + DataGrid. The size-column click is suppressed (returns early). + +- [ ] **Step 1: Write the failing test** + +Create `react-app/src/test/UnitTable.test.jsx`: +```jsx +import { describe, it, expect, vi } from 'vitest' +import { render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import fixture from './fixtures/data.json' +import { UnitTable } from '../components/UnitTable.jsx' +import { buildColumns } from '../utils/columns.jsx' + +const columns = buildColumns({ isMobile: false, showFactionCol: true, onSelectSize: () => {} }) + +describe('UnitTable', () => { + it('renders the count header with the filtered count', () => { + render( {}} />) + // "X units" is rendered (X being the filteredCount) + expect(screen.getByText(new RegExp(`${fixture.units.length} units`))).toBeInTheDocument() + }) + + it('renders all unit names in the grid', () => { + render( {}} />) + for (const u of fixture.units) { + expect(screen.getByText(u.name)).toBeInTheDocument() + } + }) + + it('calls onCellClick with the clicked row when a non-size cell is clicked', async () => { + const user = userEvent.setup() + const onCellClick = vi.fn() + const palatine = fixture.units.find(u => u.name === 'Palatine') + render() + // Click the Old cell for Palatine (60) + const oldCell = screen.getByText('60') + await user.click(oldCell) + expect(onCellClick).toHaveBeenCalledTimes(1) + expect(onCellClick).toHaveBeenCalledWith(expect.objectContaining({ name: 'Palatine' })) + }) + + it('does NOT call onCellClick when the size cell is clicked (size cell handles its own clicks)', async () => { + const user = userEvent.setup() + const onCellClick = vi.fn() + const arco = fixture.units.find(u => u.name === 'Arco-flagellants') + render() + // The size column shows a Select for multi-size units + const combobox = screen.getByRole('combobox') + await user.click(combobox) + // No onCellClick yet + expect(onCellClick).not.toHaveBeenCalled() + }) + + it('renders em-dash for new units (null original)', () => { + const newUnit = fixture.units.find(u => u.name === 'New Unit X') + render( {}} />) + // At least one em-dash should be in the document + const dashes = screen.getAllByText('—') + expect(dashes.length).toBeGreaterThan(0) + }) +}) +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: +```bash +cd react-app && npx vitest run src/test/UnitTable.test.jsx +``` + +Expected: FAIL — import error. + +- [ ] **Step 3: Implement `UnitTable.jsx`** + +Direct extraction of the DataGrid Paper (App.jsx lines 465–502). Create `react-app/src/components/UnitTable.jsx`: +```jsx +import React from 'react' +import { Box, Container, Paper, Typography } from '@mui/material' +import { DataGrid } from '@mui/x-data-grid' + +export function UnitTable({ rows, columns, filteredCount, onCellClick }) { + return ( + + + + + {filteredCount.toLocaleString()} units + + + Click a unit for points history → + + + `${row.faction}|${row.name}`} + density="compact" + autoHeight + hideFooter + disableColumnMenu + onCellClick={(p) => { + if (p.field === 'size') return + onCellClick(p.row) + }} + sx={{ + border: 'none', + width: '100%', + '& .MuiDataGrid-columnHeaders': { bgcolor: 'background.paper', borderBottom: 1, borderColor: 'divider' }, + '& .MuiDataGrid-columnHeader': { fontSize: { xs: '0.6rem', sm: '0.75rem' }, textTransform: 'uppercase', fontWeight: 600 }, + '& .MuiDataGrid-columnHeaderTitle': { fontSize: { xs: '0.6rem', sm: '0.75rem' } }, + '& .MuiDataGrid-columnSeparator': { display: 'none' }, + '& .MuiDataGrid-iconButtonContainer': { display: 'none' }, + '& .MuiDataGrid-row': { cursor: 'pointer', '&:hover': { bgcolor: 'rgba(59,130,246,0.04)' } }, + '& .MuiDataGrid-cell': { borderColor: 'divider', py: { xs: 0.5, sm: 0.5 }, display: 'flex', alignItems: 'center', overflow: 'hidden' }, + '& .MuiDataGrid-virtualScroller': { overflowX: 'hidden' }, + }} + /> + + + ) +} +``` + +- [ ] **Step 4: Run the test** + +Run: +```bash +cd react-app && npx vitest run src/test/UnitTable.test.jsx +``` + +Expected: all 5 tests pass. If `getByText('60')` matches multiple cells (e.g. New Unit X also has the em-dash which is `—`, not `60`, so this should be fine). If clicking the cell doesn't fire `onCellClick`, the DataGrid's internal click handler may be on a wrapper — click the Typography directly. + +- [ ] **Step 5: Commit** + +```bash +cd /home/kaykayyali/projects/wh40k-points-comparator && git add react-app/src/components/UnitTable.jsx react-app/src/test/UnitTable.test.jsx && git commit -m "feat(components): extract UnitTable from App" +``` + +--- + +## Task 14: Extend `test/setup.js` with `renderWithTheme` and add a shared helper + +**Files:** +- Modify: `react-app/src/test/setup.js` + +**Why:** Multiple tests need to render inside `ThemeProvider` (App test, possibly component tests). Centralize it. + +- [ ] **Step 1: Update `test/setup.js`** + +Replace the current contents of `react-app/src/test/setup.js`: +```js +import '@testing-library/jest-dom/vitest' +``` + +With: +```js +import '@testing-library/jest-dom/vitest' +import { render } from '@testing-library/react' +import { ThemeProvider, createTheme, CssBaseline } from '@mui/material' + +const theme = createTheme({ + palette: { + mode: 'dark', + background: { default: '#0a0e14', paper: '#11161e' }, + primary: { main: '#3b82f6' }, + secondary: { main: '#60a5fa' }, + success: { main: '#3fb950' }, + error: { main: '#f85149' }, + text: { primary: '#e6edf3', secondary: '#7d8590' }, + divider: '#232b38', + }, + typography: { fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Helvetica, Arial, sans-serif', fontSize: 14 }, + shape: { borderRadius: 8 }, +}) + +export function renderWithTheme(ui, options = {}) { + return render( + + + {ui} + , + options, + ) +} +``` + +- [ ] **Step 2: Run all existing tests to make sure they still pass** + +Run: +```bash +cd react-app && npx vitest run +``` + +Expected: all 56+ tests from Tasks 3–13 pass. The `renderWithTheme` helper is now available but not yet required — individual component tests can use it in the App test. + +- [ ] **Step 3: Commit** + +```bash +cd /home/kaykayyali/projects/wh40k-points-comparator && git add react-app/src/test/setup.js && git commit -m "test: add renderWithTheme helper to test setup" +``` + +--- + +## Task 15: Add `data-testid` to the App's wrapping Box (for the App test) + +**Files:** +- Modify: `react-app/src/App.jsx` (the only change to App in this task) + +**Why:** The end-to-end App test needs a stable handle for "the root Box" to scope `getByText` queries so the movers card and the table don't conflict. + +- [ ] **Step 1: Read the current `App.jsx` and find the root Box** + +The current root Box is at line 370: +```jsx + +``` + +- [ ] **Step 2: Add a `data-testid` to it** + +In `react-app/src/App.jsx`, change: +```jsx + +``` + +To: +```jsx + +``` + +- [ ] **Step 3: Commit (this is the smallest possible first edit to App.jsx; the full rewrite happens in Task 16)** + +```bash +cd /home/kaykayyali/projects/wh40k-points-comparator && git add react-app/src/App.jsx && git commit -m "refactor(app): add data-testid to root Box for tests" +``` + +--- + +## Task 16: Rewrite `App.jsx` as the orchestrator + +**Files:** +- Modify: `react-app/src/App.jsx` (full rewrite, ~90 lines) + +**Why last:** `App` imports from everything; doing it last means the new tree is stable first. + +- [ ] **Step 1: Write the failing App test (placeholder for now)** + +Create `react-app/src/test/App.test.jsx` with the full end-to-end suite. This test will FAIL until Task 16's `App.jsx` rewrite is in place. **Add it now so the test scaffolding is ready.** + +```jsx +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import fixture from './fixtures/data.json' +import { renderWithTheme } from './setup.js' +import App from '../App.jsx' + +function mockFetch(data) { + globalThis.fetch = vi.fn(() => + Promise.resolve({ json: () => Promise.resolve(data) }), + ) +} + +describe('App (end-to-end UI)', () => { + beforeEach(() => { + // Reset URL to bare pathname so each test starts fresh + window.history.replaceState(null, '', '/') + mockFetch(fixture) + }) + + it('shows a loading state then renders rows', async () => { + renderWithTheme() + expect(screen.getByText(/Loading/i)).toBeInTheDocument() + await waitFor(() => { + expect(screen.queryByText(/Loading/i)).not.toBeInTheDocument() + }) + expect(screen.getByText('Palatine')).toBeInTheDocument() + }) + + it('filters rows by search query (case-insensitive)', async () => { + const user = userEvent.setup() + renderWithTheme() + await waitFor(() => screen.getByText('Palatine')) + await user.type(screen.getByLabelText('Search'), 'pala') + await waitFor(() => { + // Palatine is still visible + expect(screen.getByText('Palatine')).toBeInTheDocument() + // Other units are filtered out + expect(screen.queryByText('Intercessor Squad')).not.toBeInTheDocument() + }) + }) + + it('filters rows by faction and hides the Faction column', async () => { + const user = userEvent.setup() + renderWithTheme() + await waitFor(() => screen.getByText('Palatine')) + await user.click(screen.getByLabelText('Faction')) + await user.click(screen.getByRole('option', { name: 'Space Marines' })) + await waitFor(() => { + expect(screen.getByText('Intercessor Squad')).toBeInTheDocument() + expect(screen.queryByText('Palatine')).not.toBeInTheDocument() + // The "Faction" column header should NOT be visible when filtered to one faction + expect(screen.queryByText('Faction')).not.toBeInTheDocument() + }) + }) + + it('filters rows by change direction (down = cheaper only)', async () => { + const user = userEvent.setup() + renderWithTheme() + await waitFor(() => screen.getByText('Palatine')) + await user.click(screen.getByLabelText('Change')) + await user.click(screen.getByRole('option', { name: /Cheaper/ })) + await waitFor(() => { + expect(screen.getByText('Palatine')).toBeInTheDocument() + // Redemptor Dreadnought is costlier, should be filtered out + expect(screen.queryByText('Redemptor Dreadnought')).not.toBeInTheDocument() + }) + }) + + it('writes filters to the URL and rehydrates from them on load', async () => { + const user = userEvent.setup() + renderWithTheme() + await waitFor(() => screen.getByText('Palatine')) + await user.click(screen.getByLabelText('Faction')) + await user.click(screen.getByRole('option', { name: 'Space Marines' })) + await waitFor(() => { + expect(window.location.search).toContain('faction=space-marines') + }) + }) + + it('rehydrates filters from URL params on load', async () => { + window.history.replaceState(null, '', '/?faction=necrons') + renderWithTheme() + await waitFor(() => { + // Only Necrons units visible + expect(screen.getByText('New Unit X')).toBeInTheDocument() + expect(screen.queryByText('Palatine')).not.toBeInTheDocument() + }) + }) + + it('updates the active size of a multi-size unit when the # cell is changed', async () => { + const user = userEvent.setup() + renderWithTheme() + await waitFor(() => screen.getByText('Arco-flagellants')) + // Arco-flagellants has 3 models (default) and 10 models + // Click the size dropdown + const sizeSelect = screen.getByRole('combobox') + await user.click(sizeSelect) + // Choose "10" (the larger size) + await user.click(screen.getByRole('option', { name: '10' })) + // The row's "New" value should update from 50 to 140 + await waitFor(() => { + // The "10" option click should have changed the displayed value + // The Old and New for the 10-models size are both 140 + const cells = screen.getAllByText('140') + expect(cells.length).toBeGreaterThan(0) + }) + }) + + it('opens the GraphModal when a non-size cell is clicked', async () => { + const user = userEvent.setup() + renderWithTheme() + await waitFor(() => screen.getByText('Palatine')) + // Click the Old cell for Palatine (50) + await user.click(screen.getByText('50')) + // Modal should now be visible with Palatine's name + await waitFor(() => { + // Both the grid cell and the modal header show "Palatine" — getAllByText + const matches = screen.getAllByText('Palatine') + expect(matches.length).toBeGreaterThan(1) + }) + // The "No historical data" message should NOT be present (Palatine has history) + expect(screen.queryByText(/no historical data/i)).not.toBeInTheDocument() + }) + + it('shows "No historical data" for units with empty history', async () => { + const user = userEvent.setup() + renderWithTheme() + await waitFor(() => screen.getByText('New Unit X')) + // Click on the "New Unit X" row (no history) + await user.click(screen.getByText('New Unit X')) + await waitFor(() => { + expect(screen.getByText(/no historical data/i)).toBeInTheDocument() + }) + }) + + it('movers cards reflect the filtered set and clicking a row opens the modal', async () => { + const user = userEvent.setup() + renderWithTheme() + await waitFor(() => screen.getByText('↓ Cheaper')) + // The cheapest unit is Palatine at -20% + // Click on the mover row containing Palatine + const cheaperPanel = screen.getByText('↓ Cheaper').parentElement + const palatineInPanel = within(cheaperPanel).getByText('Palatine') + await user.click(palatineInPanel) + // The modal opens (Palatine's name appears more than once) + await waitFor(() => { + const matches = screen.getAllByText('Palatine') + expect(matches.length).toBeGreaterThan(1) + }) + }) +}) +``` + +- [ ] **Step 2: Run the test to verify it fails (App.jsx still has the old shape)** + +Run: +```bash +cd react-app && npx vitest run src/test/App.test.jsx +``` + +Expected: FAIL — most tests will fail because the new components don't exist yet, OR (if the components do exist) because the old App.jsx still contains inline GraphModal/SizeCell/FilterBar/etc. Either way, the failure proves the test is exercising the right things. + +- [ ] **Step 3: Rewrite `App.jsx`** + +Replace the entire contents of `react-app/src/App.jsx` with the orchestrator. This is the final form per the spec. + +```jsx +import React, { useState, useMemo, useCallback, useEffect } from 'react' +import { Box, useMediaQuery, useTheme } from '@mui/material' +import { FilterBar } from './components/FilterBar.jsx' +import { MoversSection } from './components/MoversSection.jsx' +import { UnitTable } from './components/UnitTable.jsx' +import { GraphModal } from './components/GraphModal.jsx' +import { computeMovers } from './utils/movers.js' +import { buildColumns } from './utils/columns.jsx' +import { readFiltersFromUrl, writeFiltersToUrl } from './utils/url.js' + +export default function App() { + const [data, setData] = useState(null) + const initial = useMemo(() => readFiltersFromUrl(), []) + const [query, setQuery] = useState(initial.q) + const [faction, setFaction] = useState(initial.faction) + const [dir, setDir] = useState(initial.dir) + const [sizeChoice, setSizeChoice] = useState({}) + const [modalRow, setModalRow] = useState(null) + + const theme = useTheme() + const isMobile = useMediaQuery(theme.breakpoints.down('sm')) + + useEffect(() => { + fetch('./data.json').then(r => r.json()).then(setData).catch(console.error) + }, []) + + useEffect(() => { + writeFiltersToUrl({ q: query, faction, dir }) + }, [query, faction, dir]) + + const selectSize = useCallback((row, sizeLabel) => { + const key = `${row.faction}|${row.name}` + setSizeChoice(prev => ({ ...prev, [key]: sizeLabel })) + }, []) + + const augmented = useMemo(() => { + if (!data) return [] + return data.units.map(u => { + const key = `${u.faction}|${u.name}` + const chosen = sizeChoice[key] + const active = (chosen && u.sizes.find(s => s.size === chosen)) || u.sizes.find(s => s.size === u.default_size) || u.sizes[0] + return { + ...u, + size: active.size, + original: active.original, + new: active.new, + change_pct: active.change_pct, + change_pts: active.change_pts, + tier: active.tier, + } + }) + }, [data, sizeChoice]) + + const filtered = useMemo(() => { + if (!augmented.length) return [] + let view = augmented + const q = query.trim().toLowerCase() + if (q) { + view = view.filter(u => + u.name.toLowerCase().includes(q) || + u.faction_name.toLowerCase().includes(q) || + u.size.toLowerCase().includes(q) + ) + } + if (faction) view = view.filter(u => u.faction === faction) + if (dir === 'up') view = view.filter(u => u.change_pct !== null && u.change_pct > 0) + else if (dir === 'down') view = view.filter(u => u.change_pct !== null && u.change_pct < 0) + else if (dir === 'no-change') view = view.filter(u => u.change_pct === 0) + else if (dir === 'new-only') view = view.filter(u => u.original === null && u.new !== null) + else if (dir === 'old-only') view = view.filter(u => u.original !== null && u.new === null) + return view + }, [augmented, query, faction, dir]) + + const movers = useMemo(() => computeMovers(filtered), [filtered]) + const showFactionCol = !faction + const showFactionInMovers = !faction + const columns = useMemo( + () => buildColumns({ isMobile, showFactionCol, onSelectSize: selectSize }), + [isMobile, showFactionCol, selectSize], + ) + + if (!data) return Loading… + + return ( + + + + + setModalRow(null)} /> + + ) +} +``` + +- [ ] **Step 4: Run the App test** + +Run: +```bash +cd react-app && npx vitest run src/test/App.test.jsx +``` + +Expected: all 11 tests pass. If `getByLabelText('Search')` fails, ensure the FilterBar renders the TextField with `label="Search"` (it does). If `getByText('Loading…')` doesn't appear (because the fetch resolves synchronously in tests), the `waitFor` for the loaded state should still succeed — the loading state may flash and be gone before `getByText` can see it. If that test fails, change the assertion to: `await waitFor(() => screen.getByText('Palatine'))` and remove the loading-state assertion. + +- [ ] **Step 5: Run the full test suite** + +Run: +```bash +cd react-app && npx vitest run +``` + +Expected: all tests pass (utils + SizeCell + GraphModal + MoversSection + FilterBar + UnitTable + App). + +- [ ] **Step 6: Run the build** + +Run: +```bash +cd react-app && npm run build +``` + +Expected: build succeeds with no errors. The `dist/` directory is created. + +- [ ] **Step 7: Commit** + +```bash +cd /home/kaykayyali/projects/wh40k-points-comparator && git add react-app/src/App.jsx react-app/src/test/App.test.jsx && git commit -m "refactor: rewrite App.jsx as orchestrator over new component tree" +``` + +--- + +## Task 17: Final verification + +**Files:** none — verification only. + +- [ ] **Step 1: Full test run** + +```bash +cd react-app && npm test -- --run +``` + +Expected: all tests pass. + +- [ ] **Step 2: Full build** + +```bash +cd react-app && npm run build +``` + +Expected: build succeeds. + +- [ ] **Step 3: Manual smoke (matches the spec's verification step 3)** + +```bash +cd react-app && npm run dev +``` + +Then in the browser at the dev URL: +- [ ] Page loads, "Loading…" disappears, rows appear. +- [ ] Search filters by name/faction_name/size. +- [ ] Faction select filters; Faction column disappears. +- [ ] Change select filters by direction. +- [ ] URL query string updates; reload restores state. +- [ ] Multi-size `#` cell click changes the row's Old/New/Δ values. +- [ ] Click any other cell → modal opens with that unit's history. +- [ ] Modal size dropdown switches the chart's history for multi-size units. +- [ ] Empty-history units show "No historical data". +- [ ] Movers cards reflect the filtered set; clicking a row opens the same modal. + +- [ ] **Step 4: Final diff check** + +```bash +cd /home/kaykayyali/projects/wh40k-points-comparator && git diff --stat master +``` + +Expected: changes are confined to `react-app/src/` (plus `react-app/package.json` and `react-app/vite.config.js`). No changes to `main.jsx`, `index.html`, Dockerfile, or Python scripts. + +- [ ] **Step 5: Tag the refactor (optional)** + +If everything is green: +```bash +cd /home/kaykayyali/projects/wh40k-points-comparator && git tag refactor/component-tree +``` + +--- + +## Self-Review Notes (for the plan author) + +- **Spec coverage:** every bullet in the spec's "Verification" step 3 maps to a test in Task 15. Every component and util from the spec is created. The test strategy section is mirrored by Tasks 1 (install), 2 (fixture), 14 (theme helper). +- **Placeholder scan:** no TBDs. Every code block is a complete file or a complete edit. +- **Type/signature consistency:** the same function names and parameter shapes appear in both the producing task and every consuming task (e.g. `computeMovers(filtered) -> { drops, rises }` is consistent across Tasks 5, 10, 16; `buildColumns({ isMobile, showFactionCol, onSelectSize })` is consistent across Tasks 12, 13, 16; `readFiltersFromUrl() -> { q, faction, dir }` matches the destructure in Task 16's `App`). +- **Order matters:** Task 7 (`SizeCell`) is required by Task 12 (`buildColumns`). Task 12 is required by Task 13 (`UnitTable`). Task 16 (`App`) requires everything. The task numbers reflect the dependency order. +- **One non-obvious decision worth flagging:** in Task 9, the `IconButton` for the modal close gets an explicit `aria-label="✕"` so the test can find it by accessible name. This is a tiny production change (the visual `✕` still renders) and is the only accessibility attribute added during the refactor. +- **One risk worth flagging:** the DataGrid's click semantics in `UnitTable` are exercised in Task 13's test by clicking on a cell's inner ``. If MUI's DataGrid wraps clicks in a way that the Typography click doesn't bubble, the test will fail — in that case, click the parent cell by adding a `data-testid` to the Typography or use `fireEvent.click` on the cell row. Try the natural approach first.