Merge pull request 'refactor/component-tree-2' (#3) from refactor/component-tree-2 into master
Some checks failed
build / Build & Push & Dispatch (push) Failing after 25m46s

Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
2026-06-24 01:07:54 +00:00
27 changed files with 7511 additions and 444 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,384 @@
# React Component Tree Refactor — WH40K Points Comparator
**Date:** 2026-06-23
**Scope:** Restructure `react-app/src/App.jsx` from a single ~510-line file into a
presentational component tree with a `utils/` library. Zero behavior change.
## Goals
- Make each file small enough to hold in context at once (target: ≤120 lines).
- Mirror the visible UI regions in the file tree so navigation is obvious.
- Pull the densest code paths (DataGrid column defs, chart scale math) into
pure-function modules that are unit-testable without rendering.
- Preserve all current behavior exactly: same theme, same URL state, same
responsive breakpoints, same DataGrid click semantics, same modal.
## Non-Goals
- No new features, no new filters, no new columns.
- No state-management library (Redux/Zustand) — `useState`/`useMemo` in `App`.
- No E2E browser automation (Playwright/Cypress) — component-level UI tests are
the right granularity for a single-page read-only app with no backend.
- No changes to `main.jsx`, `index.html`, `vite.config.js` (beyond adding Vitest
config), Dockerfile, `docker-compose.yml`, or any of the data-build Python
scripts.
## Tech Stack (tests)
- **Test framework:** Vitest + `@testing-library/react` + `@testing-library/jest-dom`
+ `jsdom` + `@testing-library/user-event`. Vite-native, configured via a
`test` block in `vite.config.js` (no separate config file). All
devDependencies — ship no code to production.
- **Coverage target:** not a number — every behavior bullet under `Verification`
step 3 has a corresponding test. The component tests catch single-component
regressions; the App test catches wiring regressions (URL state, derived
data, modal open/close).
## Target File Tree
```
react-app/src/
main.jsx ← unchanged
App.jsx ← orchestration only (~90 lines)
components/
FilterBar.jsx ← sticky header + search/faction/change controls
MoversSection.jsx ← layout wrapper for the two movers cards
MoversPanel.jsx ← one movers card (drops OR rises)
UnitTable.jsx ← DataGrid + count header
SizeCell.jsx ← grid cell for the # column
GraphModal.jsx ← modal shell (header, size dropdown, footer)
PointsHistoryChart.jsx ← pure SVG renderer for the history line chart
utils/
format.js ← sizeShort, pctLabel, ptsLabel, changeColor
url.js ← readFiltersFromUrl, writeFiltersToUrl
movers.js ← computeMovers(filtered) -> { drops, rises }
columns.jsx ← buildColumns({ isMobile, showFactionCol, onSelectSize })
history.js ← buildChartGeometry(history, dims) -> geometry
test/
setup.js ← vitest setup (imports jest-dom matchers)
fixtures/
data.json ← minimal mock dataset for tests
App.test.jsx ← end-to-end UI tests of the full App
FilterBar.test.jsx ← search + faction + change select behavior
UnitTable.test.jsx ← DataGrid renders rows; size cell click
MoversSection.test.jsx ← drops + rises render; click opens modal
GraphModal.test.jsx ← modal opens with history; size dropdown
SizeCell.test.jsx ← single vs multi-size rendering + click
utils.test.js ← smoke tests for format/url/movers/history
```
## Module Specs
### `utils/format.js` — formatting helpers
Pure functions, no React, no module state.
| Function | Signature | Purpose |
|---|---|---|
| `sizeShort` | `(size: string) => string` | Strips trailing " model" / " models" from a unit-size label. |
| `pctLabel` | `(pct: number \| null) => string` | Formats a percent change with explicit `+` sign and one decimal. Returns `—` for null/undefined. |
| `ptsLabel` | `(pts: number \| null) => string` | Formats a points-delta with `+` prefix for positive values and bare `-` for negative (ASCII hyphen, no unicode minus). Returns `—` for null/undefined. |
| `changeColor` | `(val: number \| null) => string` | Returns `'#f85149'` (costlier/red) for `>0`, `'#3fb950'` (cheaper/green) for `<0`, `'text.secondary'` for null/zero. |
### `utils/url.js` — URL ⇄ filter state
| Function | Signature | Purpose |
|---|---|---|
| `readFiltersFromUrl` | `() => { q: string, faction: string, dir: string }` | Reads `?q=`, `?faction=`, `?dir=` from `window.location.search`. Missing keys default to `''`. |
| `writeFiltersToUrl` | `({ q, faction, dir }: { q: string, faction: string, dir: string }) => void` | Sets the three query params on the current URL via `history.replaceState`. Empties the query string when all three are empty (preserving `pathname`). |
Both functions encapsulate the `window` dependency. The component effect becomes
a one-liner: `useEffect(() => writeFiltersToUrl({ q, faction, dir }), [q, faction, dir])`.
### `utils/movers.js` — top-N by direction
| Function | Signature | Purpose |
|---|---|---|
| `computeMovers` | `(filtered: Unit[]) => { drops: Unit[], rises: Unit[] }` | Returns up to 5 cheapest (largest negative `change_pct`) and 5 costliest (largest positive `change_pct`) units from the input. Empty input → `{ drops: [], rises: [] }`. Units with `change_pct === null` are excluded. |
### `utils/columns.jsx` — DataGrid column definitions
| Function | Signature | Purpose |
|---|---|---|
| `buildColumns` | `({ isMobile, showFactionCol, onSelectSize }: { isMobile: boolean, showFactionCol: boolean, onSelectSize: (row, sizeLabel) => void }) => GridColDef[]` | Returns the full column array in the same order as today: optional Faction (unshifted when `showFactionCol`), Unit, `#`, Old, New, `Δ pts` (desktop only, inserted at index 4), `Δ %`. All column metadata (flex/minWidth/align/renderCell) preserved exactly. |
File extension is `.jsx` because `renderCell` returns JSX. Imports `SizeCell` from
`../components/SizeCell.jsx` and `pctLabel`/`ptsLabel`/`changeColor` from `./format.js`.
### `utils/history.js` — chart scale math
| Function | Signature | Purpose |
|---|---|---|
| `buildChartGeometry` | `(history: HistoryPoint[], dims: { W, H, padL, padR, padT, padB }) => { xFor, yFor, yTicks, linePath, areaPath, chartW, chartH }` | Computes the SVG geometry: scale functions, tick positions, the `M…L…` line path, the filled area path, and the inner chart dimensions. Pure; no React. |
Empty/single-point history yields empty `linePath` and `areaPath === ''` (current
behavior). The component passes these strings directly into `<path d={...} />`.
## Component Specs
### `components/SizeCell.jsx`
Same behavior as the current inline `SizeCell` in `App.jsx` (lines 3674). Props:
`{ row, onSelect }`. Returns the `#`-column cell. No change in rendering.
### `components/FilterBar.jsx`
The sticky header band (title, subtitle, search field, faction select, change
select). Props:
```
{
query: string, setQuery: (s: string) => void,
faction: string, setFaction: (s: string) => void,
dir: string, setDir: (s: string) => void,
factions: string[],
factionNames: Record<string, string>,
isMobile: boolean,
totalRows: number,
}
```
Renders the same Container with the same MUI components in the same order.
Subscript text "Codex vs. MFM v4.3 · …" takes `totalRows` from props instead of
`data.stats.total_rows`.
### `components/MoversSection.jsx`
Wraps the two movers cards in their side-by-side Container. Props:
```
{
movers: { drops: Unit[], rises: Unit[] },
onSelectUnit: (row: Unit) => void,
showFaction: boolean,
}
```
Renders two `<MoversPanel>` instances — left = drops (success/green), right =
rises (error/red). No data shaping here.
### `components/MoversPanel.jsx`
A single movers card. Props:
```
{
title: string, // "↓ Cheaper" | "↑ Costlier"
units: Unit[],
accent: 'success' | 'error', // controls the border + title color
onSelectUnit: (row: Unit) => void,
showFaction: boolean,
}
```
Renders the Paper with the accent border-left, the title in the accent color,
and a Stack of clickable rows. Row layout matches the current implementation
exactly (name, optional faction_name, mono `original→new (pct)` label).
### `components/UnitTable.jsx`
The DataGrid + count header card. Props:
```
{
rows: Unit[],
columns: GridColDef[],
filteredCount: number,
onCellClick: (row: Unit) => void,
}
```
Renders the Paper with the count header (`{filteredCount} units` and the
"Click a unit for points history →" hint) and the DataGrid. All `sx` styles
preserved exactly. The size-column click exception (`if (p.field === 'size') return`)
stays here.
### `components/GraphModal.jsx`
The modal shell. Props:
```
{ row: Unit | null, open: boolean, onClose: () => void }
```
Owns the `graphSize` local state and the `useEffect` that syncs it to
`row.size` when `row` changes. Renders the Modal + Paper with header (name +
faction_name + close button), the size dropdown (only when `sizes.length > 1`),
embeds `<PointsHistoryChart history={history} />`, and the footer summary
(first/last version) when there is more than one history point. Renders the
"No historical data" message when `history.length === 0`.
### `components/PointsHistoryChart.jsx`
Pure SVG renderer. Props:
```
{ history: HistoryPoint[], W?: number = 640, H?: number = 360 }
```
Calls `buildChartGeometry` from `utils/history.js` to compute scales, ticks,
and paths, then renders the SVG (grid lines, area fill, line path, circles +
value labels, date labels along the x-axis). The `fmtDate` helper moves into
this file as a local const since only the chart uses it. Defaults W/H match
the current `GraphModal` constants (640 × 360).
## Resulting `App.jsx`
The orchestrator. Holds:
- State: `data`, `query`, `faction`, `dir`, `sizeChoice`, `modalRow`.
- Effects: data fetch (`./data.json`); URL sync via `writeFiltersToUrl`.
- Derived: `augmented`, `filtered`, `movers = useMemo(() => computeMovers(filtered), [filtered])`,
`columns = useMemo(() => buildColumns({ isMobile, showFactionCol, onSelectSize: selectSize }), [...deps])`.
- JSX: `FilterBar`, `MoversSection`, `UnitTable`, `GraphModal` — composed in the
same Box → Container order as today. The `Loading…` guard is preserved.
Target: ~90 lines.
## Data Shape (unchanged)
`./data.json` continues to be fetched on mount. Shape:
```ts
{
units: Array<{
name: string,
faction: string,
faction_name: string,
sizes: Array<{
size: string, original: number | null, new: number | null,
change_pct: number | null, change_pts: number | null,
tier: string, history: Array<{ date: string, pts: number, version: string }>
}>,
default_size: string,
}>,
factions: string[],
faction_names: Record<string, string>,
stats: { total_rows: number, ... },
}
```
The `augmented` step in `App` flattens each unit to its active-size record —
unchanged.
## Verification
1. `cd react-app && npm test` — full Vitest suite must pass. This is the
primary verification for the refactor. Tests cover every UI behavior
listed in the manual smoke checklist below.
2. `cd react-app && npm run build` — must succeed. Catches circular imports,
missing exports, JSX syntax errors, broken path aliases.
3. `cd react-app && npm run dev` and manually verify, in order (these mirror
the test cases; the tests are the source of truth, this is a final
eyeball check):
a. Page loads, data populates, "Loading…" disappears.
b. Search field filters by name/faction_name/size (case-insensitive).
c. Faction select filters to one faction (Faction column disappears).
d. Change select filters by direction (up/down/no-change/new-only/old-only).
e. URL query string updates as any filter changes; reload restores state.
f. Click a `#` cell with multiple sizes → row's Old/New/Δ% updates.
g. Click any other cell → modal opens with the correct unit's history.
h. Modal size dropdown switches the chart's history when applicable.
i. Empty-history units render the "No historical data" message.
j. Movers cards reflect the currently filtered set; clicking a mover row
opens the same modal.
4. `git diff --stat` — should show the new files plus a much smaller
`App.jsx`. No file outside `react-app/src/` (plus the `package.json`
devDeps and the `vitest` config block in `vite.config.js`) should change.
## Test Strategy
**Framework:** Vitest + `@testing-library/react` + `@testing-library/jest-dom`
+ `jsdom` + `@testing-library/user-event`. Configured in `vite.config.js` via
a `test:` block (no separate config file). All test code lives under
`react-app/src/test/`.
**Test categories:**
1. **Pure-function smoke tests** (`test/utils.test.js`) — one or two assertions
per helper in `utils/format.js`, `utils/url.js`, `utils/movers.js`,
`utils/history.js`. No DOM, no React. Fast, deterministic, document the
contracts the components rely on.
2. **Component tests** (`test/FilterBar.test.jsx`, `UnitTable.test.jsx`,
`MoversSection.test.jsx`, `GraphModal.test.jsx`, `SizeCell.test.jsx`) —
render the component in isolation with realistic props (drawn from the
`test/fixtures/data.json` fixture), drive user behavior with
`userEvent`, assert on rendered text / class / role. The handler props
(`onSelect`, `onSelectUnit`, `onCellClick`) are jest `vi.fn()`s and
assertions check they were called with the right args.
3. **End-to-end App test** (`test/App.test.jsx`) — render `<App />` inside
the real `ThemeProvider`, mock `fetch` to return the fixture, then
exercise the full user journey from the README's feature list:
- Page loads → "Loading…" disappears → table rows appear.
- Type into search → rows narrow; clear → rows restore.
- Select a faction → rows filter to that faction; the Faction column disappears.
- Select "↓ Cheaper" → only negative `change_pct` rows remain.
- URL query string reflects the filters (read `window.location.search`).
- Reload page with `?faction=...&dir=...&q=...` → filters rehydrate from URL.
- Click a multi-size `#` cell, choose a different size → row's Old/New/Δ
values update.
- Click any other cell → modal opens with the unit's name and history.
- Click an empty-history unit → "No historical data" appears.
- Click a row in the movers card → same modal opens.
`data-testid` attributes will be added sparingly to `App.jsx` and a couple
of components only where the test needs a stable handle that the
accessible role/text does not provide (e.g. the DataGrid's internal cells,
which don't expose stable roles).
**Mocking strategy:** `globalThis.fetch` is stubbed in each test that needs
data (the App test; component tests use fixture data directly via props).
No other module is mocked — the tests run against the real implementations
of the components and utils. The `ThemeProvider` wrapper is a small helper
in `test/setup.js` to avoid repeating it in every test file.
**Coverage target:** not a number — every behavior bullet under
`Verification` step 3 has a test. The component tests give us confidence
that a one-off regression in a single component is caught; the App test
gives us confidence that the orchestrator (URL state, augmented/filtered
derivation, modal open/close) is wired correctly.
## Migration Strategy
Single-commit refactor. Steps:
1. Create `utils/format.js`, `utils/url.js`, `utils/movers.js` first (leaf
modules with no internal dependencies).
2. Create `components/SizeCell.jsx` (leaf, no internal imports beyond MUI).
3. Create `utils/history.js` (depends only on data shape).
4. Create `components/PointsHistoryChart.jsx` (depends on `utils/history.js`).
5. Create `components/GraphModal.jsx` (depends on `PointsHistoryChart` + `format`).
6. Create `components/MoversPanel.jsx` (depends on `format`).
7. Create `components/MoversSection.jsx` (depends on `MoversPanel`).
8. Create `components/FilterBar.jsx` (depends on `format`).
9. Create `utils/columns.jsx` (depends on `SizeCell` + `format`).
10. Create `components/UnitTable.jsx` (no internal `utils/` deps — receives
`rows`/`columns`/`filteredCount`/`onCellClick` from `App`).
11. Rewrite `App.jsx` last, importing from the new tree.
After each step, `npm run build` confirms nothing is broken mid-refactor.
## Risks
- **Circular imports:** `utils/columns.jsx` imports `SizeCell`; `UnitTable`
receives the columns from `App` as a prop, so it does not need to import
them. No cycle expected.
- **JSX-in-`utils/`.** Only `columns.jsx` contains JSX. Renamed `.jsx` from
`.js` to make the JSX explicit to readers and to the Vite/Babel toolchain.
Vite handles `.jsx` and `.js` identically; no config change needed.
- **Stale closures in handlers.** The `selectSize` callback is wrapped in
`useCallback` today. The split preserves this — `App` still owns it and
passes it to `buildColumns``SizeCell` via prop drilling (one level).
- **URL effect timing.** The two URL effects in `App` (read on mount, write
on change) move to `App` unchanged. The two helpers in `utils/url.js` only
encapsulate the `window` calls; they don't change the lifecycle.
- **Test data fidelity.** The fixture must cover the variety of cases the
real `data.json` has: at least one unit with multiple sizes, one with no
history, one with `change_pct === 0`, one with `original === null` (new
unit), one with `new === null` (removed unit). Each App test case
references a specific unit from the fixture by name so the test is
self-documenting.
- **DataGrid in jsdom.** `@mui/x-data-grid` works under jsdom but the
internal virtual scroller can be slow. We test by querying by role
(`cell`, `row`) and by visible text. We do not test internal column
resize / sort behavior (out of scope — the app uses none of it).
- **Test runtime.** Vitest in jsdom is fast enough that the full suite
should run in <10s. If a particular test is slow, the fix is to render
smaller fixtures, not to mock React.
## Open Questions
None.

File diff suppressed because it is too large Load Diff

View File

@@ -2,11 +2,13 @@
"name": "react-app",
"version": "1.0.0",
"description": "",
"type": "module",
"main": "index.js",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest"
},
"keywords": [],
"author": "",
@@ -20,5 +22,12 @@
"react": "^19.2.7",
"react-dom": "^19.2.7",
"vite": "^8.0.16"
},
"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"
}
}

View File

@@ -1,234 +1,31 @@
import React, { useState, useMemo, useCallback } from 'react'
import {
Box, Container, Typography, TextField, Select, MenuItem, InputLabel,
FormControl, Stack, IconButton, Modal, Paper,
useMediaQuery, useTheme,
} from '@mui/material'
import { DataGrid } from '@mui/x-data-grid'
// ── helpers ──
function sizeShort(size) {
return size.replace(/\s*models?$/, '')
}
function pctLabel(pct) {
if (pct === null || pct === undefined) return '—'
const sign = pct > 0 ? '+' : ''
return `${sign}${pct.toFixed(1)}%`
}
function ptsLabel(pts) {
if (pts === null || pts === undefined) return '—'
return pts > 0 ? `+${pts}` : `${pts}`
}
// Color: red = more expensive (bad for player), green = cheaper (good for player)
function changeColor(val) {
if (val === null || val === undefined) return 'text.secondary'
if (val > 0) return '#f85149' // costlier = red
if (val < 0) return '#3fb950' // cheaper = green
return 'text.secondary'
}
// ── Size dropdown cell ──
function SizeCell({ row, onSelect }) {
const sizes = row.sizes || []
if (sizes.length <= 1) {
return (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%' }}>
<Typography variant="body2" color="text.secondary" sx={{ fontSize: { xs: '0.68rem', sm: '0.8rem' } }}>
{sizeShort(row.size)}
</Typography>
</Box>
)
}
return (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%' }}>
<Select
size="small"
value={row.size}
onChange={(e) => { 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) => (
<MenuItem key={s.size} value={s.size} sx={{ fontSize: '0.8rem' }}>
{sizeShort(s.size)}
</MenuItem>
))}
</Select>
</Box>
)
}
// ── Line graph modal ──
function GraphModal({ row, open, onClose }) {
const [graphSize, setGraphSize] = useState(null)
React.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 || []
const W = 640, H = 360, padL = 70, padR = 24, padT = 24, padB = 48
const chartW = W - padL - padR
const chartH = H - padT - padB
const pts = history.map(h => h.pts)
const minPts = Math.min(...pts, 0)
const maxPts = Math.max(...pts, 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) })
}
const fmtDate = (d) => {
const dt = new Date(d)
return dt.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: '2-digit' })
}
return (
<Modal open={open} onClose={onClose} sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', p: { xs: 0, sm: 2 }, width: '100%', height: '100%' }}>
<Paper sx={{
maxWidth: { xs: '100%', sm: 720 },
width: '100%',
height: { xs: '100%', sm: 'auto' },
maxHeight: { xs: '100%', sm: '90vh' },
overflow: 'auto', p: { xs: 1, sm: 3 },
borderRadius: { xs: 0, sm: 2 },
display: 'flex', flexDirection: 'column',
}}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography variant="h6" fontWeight={700} sx={{ fontSize: { xs: '1rem', sm: '1.1rem' }, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{row.name}</Typography>
<Typography variant="body2" color="text.secondary" sx={{ fontSize: { xs: '0.72rem', sm: '0.8rem' } }}>{row.faction_name}</Typography>
</Box>
<IconButton onClick={onClose} size="small" sx={{ color: 'text.secondary', flexShrink: 0 }}></IconButton>
</Box>
{sizes.length > 1 && (
<FormControl size="small" sx={{ mb: 2, minWidth: 140 }}>
<InputLabel>Model count</InputLabel>
<Select
value={activeSize}
label="Model count"
onChange={(e) => setGraphSize(e.target.value)}
renderValue={(v) => sizeShort(v) + ' models'}
>
{sizes.map((s) => (
<MenuItem key={s.size} value={s.size}>{sizeShort(s.size)} models</MenuItem>
))}
</Select>
</FormControl>
)}
{history.length > 0 ? (
<Box sx={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
<Box sx={{ width: '100%', maxWidth: { xs: '100%', sm: 680 } }}>
<svg viewBox={`0 0 ${W} ${H}`} style={{ width: '100%', height: 'auto', display: 'block' }}>
{yTicks.map((t, i) => (
<g key={i}>
<line x1={padL} y1={t.y} x2={W - padR} y2={t.y} stroke="#d0d0d0" strokeWidth={0.5} />
<text x={padL - 10} y={t.y + 4} textAnchor="end" fontSize={13} fontWeight={500} fill="wheat">{t.v}</text>
</g>
))}
{areaPath && <path d={areaPath} fill="rgba(59,130,246,0.10)" />}
<path d={linePath} fill="none" stroke="#3b82f6" strokeWidth={2.5} strokeLinejoin="round" strokeLinecap="round" />
{history.map((h, i) => (
<g key={i}>
<circle cx={xFor(i)} cy={yFor(h.pts)} r={5} fill="#3b82f6" stroke="#fff" strokeWidth={2} />
<text x={xFor(i)} y={yFor(h.pts) - 12} textAnchor="middle" fontSize={14} fontWeight={700} fill="wheat">{h.pts}</text>
<text x={xFor(i)} y={H - padB + 20} textAnchor="middle" fontSize={11} fontWeight={500} fill="wheat">{fmtDate(h.date)}</text>
</g>
))}
</svg>
</Box>
</Box>
) : (
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', py: 4 }}>
No historical data for this unit.
</Typography>
)}
{history.length > 1 && (
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'space-between', gap: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
{history[0].version}: {history[0].pts}pts
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
{history[history.length - 1].version}: {history[history.length - 1].pts}pts
</Typography>
</Box>
)}
</Paper>
</Modal>
)
}
// ── Main App ──
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)
// Read initial state from URL params
const params = new URLSearchParams(window.location.search)
const [query, setQuery] = useState(params.get('q') || '')
const [faction, setFaction] = useState(params.get('faction') || 'adepta-sororitas')
const [dir, setDir] = useState(params.get('dir') || '')
const initial = useMemo(() => readFiltersFromUrl(), [])
const [query, setQuery] = useState(initial.q)
const [faction, setFaction] = useState(initial.faction || 'adepta-sororitas')
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'))
React.useEffect(() => {
useEffect(() => {
fetch('./data.json').then(r => r.json()).then(setData).catch(console.error)
}, [])
// Sync filter state to URL whenever it changes
React.useEffect(() => {
const p = new URLSearchParams()
if (query) p.set('q', query)
if (faction) p.set('faction', faction)
if (dir) p.set('dir', dir)
const qs = p.toString()
const newUrl = qs ? `?${qs}` : window.location.pathname
window.history.replaceState(null, '', newUrl)
useEffect(() => {
writeFiltersToUrl({ q: query, faction, dir })
}, [query, faction, dir])
const selectSize = useCallback((row, sizeLabel) => {
@@ -274,234 +71,38 @@ export default function App() {
return view
}, [augmented, query, faction, dir])
// Movers — based on the currently filtered view
const movers = useMemo(() => {
if (!filtered.length) 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 }
}, [filtered])
const movers = useMemo(() => computeMovers(filtered), [filtered])
const showFactionCol = !faction
const showFactionInMovers = !faction // hide faction name in movers when filtered to a faction
// Columns: all flex-based
const columns = useMemo(() => {
const cols = [
{
field: 'name', headerName: 'Unit', flex: 3, minWidth: 80,
renderCell: (p) => (
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%', height: '100%', overflow: 'hidden' }}>
<Typography
variant="body2"
fontWeight={600}
sx={{
fontSize: { xs: '0.68rem', sm: '0.8rem' },
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
'&:hover': { textDecoration: 'underline', color: 'primary.main' },
cursor: 'pointer',
}}
>
{p.row.name}
</Typography>
</Box>
),
},
{
field: 'size', headerName: '#', flex: 0.6, minWidth: 36,
renderCell: (p) => <SizeCell row={p.row} onSelect={selectSize} />,
},
{
field: 'original', headerName: 'Old', flex: 0.8, minWidth: 36, align: 'right', headerAlign: 'right',
renderCell: (p) => (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', width: '100%', height: '100%' }}>
<Typography sx={{ fontFamily: 'monospace', fontSize: { xs: '0.68rem', sm: '0.8rem' } }}>{p.row.original ?? '—'}</Typography>
</Box>
),
},
{
field: 'new', headerName: 'New', flex: 0.8, minWidth: 36, align: 'right', headerAlign: 'right',
renderCell: (p) => (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', width: '100%', height: '100%' }}>
<Typography sx={{ fontFamily: 'monospace', fontWeight: 600, fontSize: { xs: '0.68rem', sm: '0.8rem' } }}>{p.row.new ?? '—'}</Typography>
</Box>
),
},
{
field: 'change_pct', headerName: 'Δ %', flex: 1, minWidth: 44, align: 'right', headerAlign: 'right',
renderCell: (p) => (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', width: '100%', height: '100%' }}>
<Typography sx={{ fontFamily: 'monospace', fontWeight: 600, fontSize: { xs: '0.68rem', sm: '0.8rem' }, color: changeColor(p.row.change_pct) }}>{pctLabel(p.row.change_pct)}</Typography>
</Box>
),
},
]
// Δ 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) => (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', width: '100%', height: '100%' }}>
<Typography sx={{ fontFamily: 'monospace', fontSize: { xs: '0.68rem', sm: '0.8rem' }, color: changeColor(p.row.change_pts) }}>{ptsLabel(p.row.change_pts)}</Typography>
</Box>
),
})
}
if (showFactionCol) {
cols.unshift({
field: 'faction_name', headerName: 'Faction', flex: 1.5, minWidth: 80,
renderCell: (p) => (
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%', height: '100%', overflow: 'hidden' }}>
<Typography variant="body2" color="text.secondary" sx={{ fontSize: { xs: '0.68rem', sm: '0.8rem' }, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{p.row.faction_name}</Typography>
</Box>
),
})
}
return cols
}, [selectSize, showFactionCol, isMobile])
const showFactionInMovers = !faction
const columns = useMemo(
() => buildColumns({ isMobile, showFactionCol, onSelectSize: selectSize }),
[isMobile, showFactionCol, selectSize],
)
if (!data) return <Box sx={{ p: 4, color: 'text.secondary' }}>Loading</Box>
return (
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default' }}>
{/* Sticky filter bar */}
<Box sx={{
borderBottom: 1, borderColor: 'divider', bgcolor: 'background.paper',
position: 'sticky', top: 0, zIndex: 1100,
}}>
<Container maxWidth="xl" sx={{ py: { xs: 0.75, sm: 1 }, px: { xs: 1, sm: 2 } }}>
<Typography variant="h6" fontWeight={700} sx={{ display: { xs: 'none', sm: 'block' }, mb: 0.5, fontSize: '1.1rem' }}>
Points Comparator
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ display: { xs: 'none', sm: 'block' }, mb: 1, fontSize: '0.75rem' }}>
Codex vs. MFM v4.3 · {data.stats.total_rows.toLocaleString()} units · <span style={{ color: '#3fb950' }}>green = cheaper</span> · <span style={{ color: '#f85149' }}>red = costlier</span>
</Typography>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={{ xs: 0.5, sm: 1 }} sx={{ alignItems: { sm: 'flex-end' } }}>
<TextField
size="small"
label="Search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Unit name…"
sx={{ flex: 2, minWidth: { xs: '100%', sm: 180 } }}
/>
<Stack direction="row" spacing={0.5} sx={{ flex: 1, minWidth: 0 }}>
<FormControl size="small" sx={{ flex: 1, minWidth: 0 }}>
<InputLabel>Faction</InputLabel>
<Select value={faction} label="Faction" onChange={(e) => setFaction(e.target.value)}>
<MenuItem value="">All</MenuItem>
{data.factions.map(slug => (
<MenuItem key={slug} value={slug}>{data.faction_names[slug] || slug}</MenuItem>
))}
</Select>
</FormControl>
<FormControl size="small" sx={{ flex: 1, minWidth: 0 }}>
<InputLabel>{isMobile ? 'Δ' : 'Change'}</InputLabel>
<Select value={dir} label={isMobile ? 'Δ' : 'Change'} onChange={(e) => setDir(e.target.value)}>
<MenuItem value="">All</MenuItem>
<MenuItem value="up"> Costlier</MenuItem>
<MenuItem value="down"> Cheaper</MenuItem>
<MenuItem value="no-change"> No change</MenuItem>
<MenuItem value="new-only">+ New only</MenuItem>
<MenuItem value="old-only"> Removed</MenuItem>
</Select>
</FormControl>
</Stack>
</Stack>
</Container>
</Box>
{/* Movers — based on current filtered view, click opens modal */}
<Container maxWidth="xl" sx={{ py: { xs: 0.5, sm: 2 }, px: { xs: 1, sm: 2 } }}>
<Box sx={{ display: 'flex', gap: { xs: 0.5, sm: 2 }, flexDirection: 'row' }}>
{/* Drops = cheaper = green (good for player) */}
<Paper sx={{ flex: 1, p: { xs: 0.5, sm: 2 }, borderRadius: { xs: 1, sm: 2 }, borderLeft: 3, borderColor: 'success.main', minWidth: 0 }}>
<Typography variant="subtitle2" fontWeight={700} sx={{ mb: 0.25, fontSize: { xs: '0.65rem', sm: '0.8rem' }, color: 'success.main' }}> Cheaper</Typography>
<Stack spacing={0}>
{movers.drops.map((u, i) => (
<Box key={i} sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'pointer', py: 0.15, px: 0.25, borderRadius: 0.5, '&:hover': { bgcolor: 'rgba(63,185,80,0.08)' } }}
onClick={() => setModalRow(u)}>
<Box sx={{ minWidth: 0, overflow: 'hidden', flex: 1 }}>
<Typography variant="body2" fontWeight={600} sx={{ fontSize: { xs: '0.6rem', sm: '0.8rem' }, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', '&:hover': { color: 'primary.main' } }}>{u.name}</Typography>
{showFactionInMovers && (
<Typography variant="caption" color="text.secondary" sx={{ fontSize: { xs: '0.55rem', sm: '0.7rem' }, display: { xs: 'none', sm: 'block' } }}>{u.faction_name}</Typography>
)}
</Box>
<Typography variant="body2" sx={{ fontFamily: 'monospace', color: 'success.main', fontWeight: 700, fontSize: { xs: '0.55rem', sm: '0.8rem' }, whiteSpace: 'nowrap', ml: 0.5 }}>
{u.original}{u.new} ({pctLabel(u.change_pct)})
</Typography>
</Box>
))}
</Stack>
</Paper>
{/* Rises = costlier = red (bad for player) */}
<Paper sx={{ flex: 1, p: { xs: 0.5, sm: 2 }, borderRadius: { xs: 1, sm: 2 }, borderLeft: 3, borderColor: 'error.main', minWidth: 0 }}>
<Typography variant="subtitle2" fontWeight={700} sx={{ mb: 0.25, fontSize: { xs: '0.65rem', sm: '0.8rem' }, color: 'error.main' }}> Costlier</Typography>
<Stack spacing={0}>
{movers.rises.map((u, i) => (
<Box key={i} sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'pointer', py: 0.15, px: 0.25, borderRadius: 0.5, '&:hover': { bgcolor: 'rgba(248,81,73,0.08)' } }}
onClick={() => setModalRow(u)}>
<Box sx={{ minWidth: 0, overflow: 'hidden', flex: 1 }}>
<Typography variant="body2" fontWeight={600} sx={{ fontSize: { xs: '0.6rem', sm: '0.8rem' }, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', '&:hover': { color: 'primary.main' } }}>{u.name}</Typography>
{showFactionInMovers && (
<Typography variant="caption" color="text.secondary" sx={{ fontSize: { xs: '0.55rem', sm: '0.7rem' }, display: { xs: 'none', sm: 'block' } }}>{u.faction_name}</Typography>
)}
</Box>
<Typography variant="body2" sx={{ fontFamily: 'monospace', color: 'error.main', fontWeight: 700, fontSize: { xs: '0.55rem', sm: '0.8rem' }, whiteSpace: 'nowrap', ml: 0.5 }}>
{u.original}{u.new} ({pctLabel(u.change_pct)})
</Typography>
</Box>
))}
</Stack>
</Paper>
</Box>
</Container>
{/* Data Grid */}
<Container maxWidth="xl" sx={{ pb: 4, px: { xs: 1, sm: 2 } }}>
<Paper sx={{ borderRadius: 2, overflow: 'hidden' }}>
{/* Count label — inside the table card, anchored */}
<Box sx={{ px: { xs: 1, sm: 2 }, py: 1, borderBottom: 1, borderColor: 'divider', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: { xs: '0.65rem', sm: '0.7rem' } }}>
<b style={{ color: 'text.primary' }}>{filtered.length.toLocaleString()}</b> units
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: { xs: '0.6rem', sm: '0.65rem' }, display: { xs: 'none', sm: 'block' } }}>
Click a unit for points history
</Typography>
</Box>
<DataGrid
rows={filtered}
columns={columns}
getRowId={(row) => `${row.faction}|${row.name}`}
density="compact"
autoHeight
hideFooter
disableColumnMenu
onCellClick={(p) => {
if (p.field === 'size') return
setModalRow(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' },
}}
/>
</Paper>
</Container>
{/* Graph Modal */}
<FilterBar
query={query} setQuery={setQuery}
faction={faction} setFaction={setFaction}
dir={dir} setDir={setDir}
factions={data.factions}
factionNames={data.faction_names}
isMobile={isMobile}
totalRows={data.stats.total_rows}
/>
<MoversSection
movers={movers}
onSelectUnit={setModalRow}
showFaction={showFactionInMovers}
/>
<UnitTable
rows={filtered}
columns={columns}
filteredCount={filtered.length}
onCellClick={setModalRow}
/>
<GraphModal row={modalRow} open={!!modalRow} onClose={() => setModalRow(null)} />
</Box>
)

View File

@@ -0,0 +1,61 @@
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 (
<Box sx={{
borderBottom: 1, borderColor: 'divider', bgcolor: 'background.paper',
position: 'sticky', top: 0, zIndex: 1100,
}}>
<Container maxWidth="xl" sx={{ py: { xs: 0.75, sm: 1 }, px: { xs: 1, sm: 2 } }}>
<Typography variant="h6" fontWeight={700} sx={{ display: { xs: 'none', sm: 'block' }, mb: 0.5, fontSize: '1.1rem' }}>
Points Comparator
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ display: { xs: 'none', sm: 'block' }, mb: 1, fontSize: '0.75rem' }}>
Codex vs. MFM v4.3 · {totalRows.toLocaleString()} units · <span style={{ color: '#3fb950' }}>green = cheaper</span> · <span style={{ color: '#f85149' }}>red = costlier</span>
</Typography>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={{ xs: 0.5, sm: 1 }} sx={{ alignItems: { sm: 'flex-end' } }}>
<TextField
size="small"
label="Search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Unit name…"
sx={{ flex: 2, minWidth: { xs: '100%', sm: 180 } }}
/>
<Stack direction="row" spacing={0.5} sx={{ flex: 1, minWidth: 0 }}>
<FormControl size="small" sx={{ flex: 1, minWidth: 0 }}>
<InputLabel id="filterbar-faction-label">Faction</InputLabel>
<Select labelId="filterbar-faction-label" value={faction} label="Faction" onChange={(e) => setFaction(e.target.value)}>
<MenuItem value="">All</MenuItem>
{factions.map(slug => (
<MenuItem key={slug} value={slug}>{factionNames[slug] || slug}</MenuItem>
))}
</Select>
</FormControl>
<FormControl size="small" sx={{ flex: 1, minWidth: 0 }}>
<InputLabel id="filterbar-dir-label">{isMobile ? 'Δ' : 'Change'}</InputLabel>
<Select labelId="filterbar-dir-label" value={dir} label={isMobile ? 'Δ' : 'Change'} onChange={(e) => setDir(e.target.value)}>
<MenuItem value="">All</MenuItem>
<MenuItem value="up"> Costlier</MenuItem>
<MenuItem value="down"> Cheaper</MenuItem>
<MenuItem value="no-change"> No change</MenuItem>
<MenuItem value="new-only">+ New only</MenuItem>
<MenuItem value="old-only"> Removed</MenuItem>
</Select>
</FormControl>
</Stack>
</Stack>
</Container>
</Box>
)
}

View File

@@ -0,0 +1,79 @@
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 (
<Modal open={open} onClose={onClose} sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', p: { xs: 0, sm: 2 }, width: '100%', height: '100%' }}>
<Paper sx={{
maxWidth: { xs: '100%', sm: 720 },
width: '100%',
height: { xs: '100%', sm: 'auto' },
maxHeight: { xs: '100%', sm: '90vh' },
overflow: 'auto', p: { xs: 1, sm: 3 },
borderRadius: { xs: 0, sm: 2 },
display: 'flex', flexDirection: 'column',
}}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography variant="h6" fontWeight={700} sx={{ fontSize: { xs: '1rem', sm: '1.1rem' }, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{row.name}</Typography>
<Typography variant="body2" color="text.secondary" sx={{ fontSize: { xs: '0.72rem', sm: '0.8rem' } }}>{row.faction_name}</Typography>
</Box>
<IconButton onClick={onClose} size="small" sx={{ color: 'text.secondary', flexShrink: 0 }} aria-label="✕"></IconButton>
</Box>
{sizes.length > 1 && (
<FormControl size="small" sx={{ mb: 2, minWidth: 140 }}>
<InputLabel>Model count</InputLabel>
<Select
value={activeSize}
label="Model count"
onChange={(e) => setGraphSize(e.target.value)}
renderValue={(v) => sizeShort(v) + ' models'}
>
{sizes.map((s) => (
<MenuItem key={s.size} value={s.size}>{sizeShort(s.size)} models</MenuItem>
))}
</Select>
</FormControl>
)}
{history.length > 0 ? (
<PointsHistoryChart history={history} />
) : (
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', py: 4 }}>
No historical data for this unit.
</Typography>
)}
{history.length > 1 && (
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'space-between', gap: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
{history[0].version}: {history[0].pts}pts
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
{history[history.length - 1].version}: {history[history.length - 1].pts}pts
</Typography>
</Box>
)}
</Paper>
</Modal>
)
}

View File

@@ -0,0 +1,30 @@
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 (
<Paper sx={{ flex: 1, p: { xs: 0.5, sm: 2 }, borderRadius: { xs: 1, sm: 2 }, borderLeft: 3, borderColor: `${accent}.main`, minWidth: 0 }}>
<Typography variant="subtitle2" fontWeight={700} sx={{ mb: 0.25, fontSize: { xs: '0.65rem', sm: '0.8rem' }, color: `${accent}.main` }}>{title}</Typography>
<Stack spacing={0}>
{units.map((u, i) => (
<Box
key={i}
sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'pointer', py: 0.15, px: 0.25, borderRadius: 0.5, '&:hover': { bgcolor: accent === 'success' ? 'rgba(63,185,80,0.08)' : 'rgba(248,81,73,0.08)' } }}
onClick={() => onSelectUnit(u)}
>
<Box sx={{ minWidth: 0, overflow: 'hidden', flex: 1 }}>
<Typography variant="body2" fontWeight={600} sx={{ fontSize: { xs: '0.6rem', sm: '0.8rem' }, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', '&:hover': { color: 'primary.main' } }}>{u.name}</Typography>
{showFaction && (
<Typography variant="caption" color="text.secondary" sx={{ fontSize: { xs: '0.55rem', sm: '0.7rem' }, display: { xs: 'none', sm: 'block' } }}>{u.faction_name}</Typography>
)}
</Box>
<Typography variant="body2" sx={{ fontFamily: 'monospace', color: `${accent}.main`, fontWeight: 700, fontSize: { xs: '0.55rem', sm: '0.8rem' }, whiteSpace: 'nowrap', ml: 0.5 }}>
{u.original}{u.new} ({pctLabel(u.change_pct)})
</Typography>
</Box>
))}
</Stack>
</Paper>
)
}

View File

@@ -0,0 +1,26 @@
import React from 'react'
import { Box, Container } from '@mui/material'
import { MoversPanel } from './MoversPanel.jsx'
export function MoversSection({ movers, onSelectUnit, showFaction }) {
return (
<Container maxWidth="xl" sx={{ py: { xs: 0.5, sm: 2 }, px: { xs: 1, sm: 2 } }}>
<Box sx={{ display: 'flex', gap: { xs: 0.5, sm: 2 }, flexDirection: 'row' }}>
<MoversPanel
title="↓ Cheaper"
units={movers.drops}
accent="success"
onSelectUnit={onSelectUnit}
showFaction={showFaction}
/>
<MoversPanel
title="↑ Costlier"
units={movers.rises}
accent="error"
onSelectUnit={onSelectUnit}
showFaction={showFaction}
/>
</Box>
</Container>
)
}

View File

@@ -0,0 +1,41 @@
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 (
<Box sx={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
<Box sx={{ width: '100%', maxWidth: { xs: '100%', sm: 680 } }}>
<svg viewBox={`0 0 ${W} ${H}`} style={{ width: '100%', height: 'auto', display: 'block' }}>
{yTicks.map((t, i) => (
<g key={i}>
<line x1={PADL} y1={t.y} x2={W - PADR} y2={t.y} stroke="#d0d0d0" strokeWidth={0.5} />
<text x={PADL - 10} y={t.y + 4} textAnchor="end" fontSize={13} fontWeight={500} fill="wheat">{t.v}</text>
</g>
))}
{areaPath && <path d={areaPath} fill="rgba(59,130,246,0.10)" />}
<path d={linePath} fill="none" stroke="#3b82f6" strokeWidth={2.5} strokeLinejoin="round" strokeLinecap="round" />
{history.map((h, i) => (
<g key={i}>
<circle cx={xFor(i)} cy={yFor(h.pts)} r={5} fill="#3b82f6" stroke="#fff" strokeWidth={2} />
<text x={xFor(i)} y={yFor(h.pts) - 12} textAnchor="middle" fontSize={14} fontWeight={700} fill="wheat">{h.pts}</text>
<text x={xFor(i)} y={H - PADB + 20} textAnchor="middle" fontSize={11} fontWeight={500} fill="wheat">{fmtDate(h.date)}</text>
</g>
))}
</svg>
</Box>
</Box>
)
}

View File

@@ -0,0 +1,43 @@
import React from 'react'
import { Box, Typography, Select, MenuItem } from '@mui/material'
import { sizeShort } from '../utils/format.js'
export function SizeCell({ row, onSelect }) {
const sizes = row.sizes || []
if (sizes.length <= 1) {
return (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%' }}>
<Typography variant="body2" color="text.secondary" sx={{ fontSize: { xs: '0.68rem', sm: '0.8rem' } }}>
{sizeShort(row.size)}
</Typography>
</Box>
)
}
return (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%' }}>
<Select
size="small"
value={row.size}
onChange={(e) => { 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) => (
<MenuItem key={s.size} value={s.size} sx={{ fontSize: '0.8rem' }}>
{sizeShort(s.size)}
</MenuItem>
))}
</Select>
</Box>
)
}

View File

@@ -0,0 +1,45 @@
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 (
<Container maxWidth="xl" sx={{ pb: 4, px: { xs: 1, sm: 2 } }}>
<Paper sx={{ borderRadius: 2, overflow: 'hidden' }}>
<Box sx={{ px: { xs: 1, sm: 2 }, py: 1, borderBottom: 1, borderColor: 'divider', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: { xs: '0.65rem', sm: '0.7rem' } }}>
<b style={{ color: 'text.primary' }}>{filteredCount.toLocaleString()}</b> units
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: { xs: '0.6rem', sm: '0.65rem' }, display: { xs: 'none', sm: 'block' } }}>
Click a unit for points history
</Typography>
</Box>
<DataGrid
rows={rows}
columns={columns}
getRowId={(row) => `${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' },
}}
/>
</Paper>
</Container>
)
}

View File

@@ -0,0 +1,178 @@
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(<App />)
expect(screen.getByText(/Loading/i)).toBeInTheDocument()
await waitFor(() => {
expect(screen.queryByText(/Loading/i)).not.toBeInTheDocument()
})
// Palatine appears in both the movers panel and the data grid
expect(screen.getAllByText('Palatine').length).toBeGreaterThan(0)
})
it('filters rows by search query (case-insensitive)', async () => {
const user = userEvent.setup()
renderWithTheme(<App />)
await waitFor(() => screen.getAllByText('Palatine')[0])
await user.type(screen.getByLabelText('Search'), 'pala')
await waitFor(() => {
// Palatine is still visible (in both movers + grid)
expect(screen.getAllByText('Palatine').length).toBeGreaterThan(0)
// 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(<App />)
await waitFor(() => screen.getAllByText('Palatine')[0])
await user.click(screen.getByLabelText('Faction'))
await user.click(screen.getByRole('option', { name: 'Space Marines' }))
await waitFor(() => {
// Intercessor Squad appears in movers (drops, -5%) AND grid
expect(screen.getAllByText('Intercessor Squad').length).toBeGreaterThan(0)
expect(screen.queryByText('Palatine')).not.toBeInTheDocument()
// The "Faction" column header should NOT be visible when filtered to one faction
// (note: the FilterBar's "Faction" label is still present, so we scope to column headers)
expect(screen.queryByRole('columnheader', { name: 'Faction' })).not.toBeInTheDocument()
})
})
it('filters rows by change direction (down = cheaper only)', async () => {
const user = userEvent.setup()
renderWithTheme(<App />)
await waitFor(() => screen.getAllByText('Palatine')[0])
await user.click(screen.getByLabelText('Change'))
await user.click(screen.getByRole('option', { name: /Cheaper/ }))
await waitFor(() => {
expect(screen.getAllByText('Palatine').length).toBeGreaterThan(0)
// 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(<App />)
await waitFor(() => screen.getAllByText('Palatine')[0])
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(<App />)
await waitFor(() => {
// Only Necrons units visible
expect(screen.getByText('New Unit X')).toBeInTheDocument()
expect(screen.queryByText('Palatine')).not.toBeInTheDocument()
})
})
it('defaults to Adepta Sororitas and hides the Faction column on bare-URL load', async () => {
renderWithTheme(<App />)
// Wait for data load — Palatine is Adepta Sororitas, so it must be present
await waitFor(() => screen.getAllByText('Palatine')[0])
// Intercessor Squad is Space Marines — must NOT be visible because default faction is 'adepta-sororitas'
expect(screen.queryByText('Intercessor Squad')).not.toBeInTheDocument()
// Faction column header must be hidden because a faction is active
expect(screen.queryByRole('columnheader', { name: 'Faction' })).not.toBeInTheDocument()
})
it('updates the active size of a multi-size unit when the # cell is changed', async () => {
const user = userEvent.setup()
renderWithTheme(<App />)
// Arco-flagellants appears in both movers (rises, +11.11%) and grid
await waitFor(() => screen.getAllByText('Arco-flagellants')[0])
// Arco-flagellants has 3 models (default) and 10 models
// The size cell combobox is the only combobox on the page that is NOT a FilterBar dropdown
// (FilterBar's comboboxes are labelled by filterbar-faction-label / filterbar-dir-label)
const sizeSelect = screen.getAllByRole('combobox').find(
c => c.getAttribute('aria-labelledby') !== 'filterbar-faction-label'
&& c.getAttribute('aria-labelledby') !== 'filterbar-dir-label',
)
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(<App />)
await waitFor(() => screen.getAllByText('Palatine')[0])
// Palatine's Old=50, New=40. "50" also appears in Arco-flagellants' New column,
// so we scope the click to Palatine's DataGrid row (data-id uses `${faction}|${name}`).
const palatineRow = document.querySelector('[data-id="adepta-sororitas|Palatine"]')
expect(palatineRow).not.toBeNull()
// Click the Old cell for Palatine (50)
await user.click(within(palatineRow).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(<App />)
await waitFor(() => screen.getAllByText('Palatine')[0])
// Default faction is Adepta Sororitas; switch to "All" so Necron's New Unit X is visible
await user.click(screen.getByLabelText('Faction'))
await user.click(screen.getByRole('option', { name: 'All' }))
await waitFor(() => screen.getAllByText('New Unit X')[0])
// Click on the "New Unit X" row (no history)
await user.click(screen.getAllByText('New Unit X')[0])
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(<App />)
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)
})
})
})

View File

@@ -0,0 +1,72 @@
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(<FilterBar {...defaultProps} />)
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(<FilterBar {...defaultProps} setQuery={setQuery} />)
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(<FilterBar {...defaultProps} setFaction={setFaction} />)
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(<FilterBar {...defaultProps} setDir={setDir} />)
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(<FilterBar {...defaultProps} isMobile={true} />)
// 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(<FilterBar {...defaultProps} />)
await user.click(screen.getByLabelText('Faction'))
expect(screen.getByRole('option', { name: 'All' })).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,65 @@
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(<GraphModal row={row} open={true} onClose={onClose} />)
}
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).
// Note: MUI's Select renders an arrow SVG (viewBox 0 0 24 24) before the chart SVG,
// so we filter for the chart SVG (the one with circles) instead of taking the first.
const chartSvg = Array.from(document.querySelectorAll('svg')).find(s => s.querySelector('circle'))
expect(chartSvg).toBeInTheDocument()
expect(chartSvg.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(<GraphModal row={null} open={true} onClose={() => {}} />)
// 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()
})
})

View File

@@ -0,0 +1,71 @@
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(
<MoversSection
movers={{ drops: [palatine], rises: [dread] }}
onSelectUnit={() => {}}
showFaction={true}
/>,
)
expect(screen.getByText('↓ Cheaper')).toBeInTheDocument()
expect(screen.getByText('↑ Costlier')).toBeInTheDocument()
})
it('lists units in the drops panel under "Cheaper"', () => {
render(
<MoversSection
movers={{ drops: [palatine, intercessor], rises: [dread] }}
onSelectUnit={() => {}}
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(
<MoversSection
movers={{ drops: [palatine], rises: [] }}
onSelectUnit={() => {}}
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.queryAllByText(/Adepta Sororitas/)
expect(rows.length).toBe(0)
})
it('calls onSelectUnit with the clicked row', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
render(
<MoversSection
movers={{ drops: [palatine], rises: [] }}
onSelectUnit={onSelect}
showFaction={true}
/>,
)
await user.click(screen.getByText('Palatine'))
expect(onSelect).toHaveBeenCalledTimes(1)
expect(onSelect).toHaveBeenCalledWith(palatine)
})
})

View File

@@ -0,0 +1,49 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { SizeCell } from '../components/SizeCell.jsx'
function renderWithTheme(ui) {
return render(<div>{ui}</div>)
}
describe('SizeCell', () => {
it('renders plain text when the unit has one size', () => {
const row = { size: '1 model', sizes: [{ size: '1 model' }] }
renderWithTheme(<SizeCell row={row} onSelect={() => {}} />)
expect(screen.getByText('1')).toBeInTheDocument()
// No select element should be present
expect(screen.queryByRole('combobox')).not.toBeInTheDocument()
})
it('renders a Select dropdown when the unit has multiple sizes', () => {
const row = {
size: '3 models',
sizes: [{ size: '3 models' }, { size: '10 models' }],
}
renderWithTheme(<SizeCell row={row} onSelect={() => {}} />)
expect(screen.getByRole('combobox')).toBeInTheDocument()
expect(screen.getByText('3')).toBeInTheDocument() // current value, shortened
})
it('calls onSelect with the row and the chosen size label', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
const row = {
size: '3 models',
sizes: [{ size: '3 models' }, { size: '10 models' }],
}
renderWithTheme(<SizeCell row={row} onSelect={onSelect} />)
await user.click(screen.getByRole('combobox'))
await user.click(screen.getByRole('option', { name: '10' }))
expect(onSelect).toHaveBeenCalledTimes(1)
expect(onSelect).toHaveBeenCalledWith(row, '10 models')
})
it('treats missing sizes array as single-size', () => {
const row = { size: '1 model' } // no `sizes` key
renderWithTheme(<SizeCell row={row} onSelect={() => {}} />)
expect(screen.getByText('1')).toBeInTheDocument()
expect(screen.queryByRole('combobox')).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,59 @@
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(<UnitTable rows={fixture.units} columns={columns} filteredCount={fixture.units.length} onCellClick={() => {}} />)
// "X units" is rendered (X being the filteredCount). The text is split across
// <b>{X}</b> and ' units' so a regex/getByText matcher can't find the combined
// text — instead find the <b>{X}</b> and check the parent for the "units" suffix.
const countEl = screen.getByText(`${fixture.units.length}`)
expect(countEl.parentElement).toHaveTextContent(new RegExp(`${fixture.units.length}\\s*units`))
})
it('renders all unit names in the grid', () => {
render(<UnitTable rows={fixture.units} columns={columns} filteredCount={fixture.units.length} onCellClick={() => {}} />)
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(<UnitTable rows={[palatine]} columns={columns} filteredCount={1} onCellClick={onCellClick} />)
// Click the Old cell for Palatine. Note: the brief said '60' but the fixture has
// Palatine.original = 50, so we use '50' (the actual Old value).
const oldCell = screen.getByText('50')
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(<UnitTable rows={[arco]} columns={columns} filteredCount={1} onCellClick={onCellClick} />)
// 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(<UnitTable rows={[newUnit]} columns={columns} filteredCount={1} onCellClick={() => {}} />)
// At least one em-dash should be in the document
const dashes = screen.getAllByText('—')
expect(dashes.length).toBeGreaterThan(0)
})
})

207
react-app/src/test/fixtures/data.json vendored Normal file
View File

@@ -0,0 +1,207 @@
{
"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": []
}
]
}
]
}

31
react-app/src/test/setup.js vendored Normal file
View File

@@ -0,0 +1,31 @@
import '@testing-library/jest-dom/vitest'
import { createElement } from 'react'
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(
createElement(
ThemeProvider,
{ theme },
createElement(CssBaseline),
ui,
),
options,
)
}

View File

@@ -0,0 +1,173 @@
import { describe, it, expect } from 'vitest'
import { buildChartGeometry } from '../utils/history.js'
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')
})
})
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')
})
})
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')
})
})
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'])
})
})
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).toBeDefined()
})
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)
})
})

View File

@@ -0,0 +1,80 @@
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) => (
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%', height: '100%', overflow: 'hidden' }}>
<Typography
variant="body2"
fontWeight={600}
sx={{
fontSize: { xs: '0.68rem', sm: '0.8rem' },
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
'&:hover': { textDecoration: 'underline', color: 'primary.main' },
cursor: 'pointer',
}}
>
{p.row.name}
</Typography>
</Box>
),
},
{
field: 'size', headerName: '#', flex: 0.6, minWidth: 36,
renderCell: (p) => <SizeCell row={p.row} onSelect={onSelectSize} />,
},
{
field: 'original', headerName: 'Old', flex: 0.8, minWidth: 36, align: 'right', headerAlign: 'right',
renderCell: (p) => (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', width: '100%', height: '100%' }}>
<Typography sx={{ fontFamily: 'monospace', fontSize: { xs: '0.68rem', sm: '0.8rem' } }}>{p.row.original ?? '—'}</Typography>
</Box>
),
},
{
field: 'new', headerName: 'New', flex: 0.8, minWidth: 36, align: 'right', headerAlign: 'right',
renderCell: (p) => (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', width: '100%', height: '100%' }}>
<Typography sx={{ fontFamily: 'monospace', fontWeight: 600, fontSize: { xs: '0.68rem', sm: '0.8rem' } }}>{p.row.new ?? '—'}</Typography>
</Box>
),
},
{
field: 'change_pct', headerName: 'Δ %', flex: 1, minWidth: 44, align: 'right', headerAlign: 'right',
renderCell: (p) => (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', width: '100%', height: '100%' }}>
<Typography sx={{ fontFamily: 'monospace', fontWeight: 600, fontSize: { xs: '0.68rem', sm: '0.8rem' }, color: changeColor(p.row.change_pct) }}>{pctLabel(p.row.change_pct)}</Typography>
</Box>
),
},
]
// Δ 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) => (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', width: '100%', height: '100%' }}>
<Typography sx={{ fontFamily: 'monospace', fontSize: { xs: '0.68rem', sm: '0.8rem' }, color: changeColor(p.row.change_pts) }}>{ptsLabel(p.row.change_pts)}</Typography>
</Box>
),
})
}
if (showFactionCol) {
cols.unshift({
field: 'faction_name', headerName: 'Faction', flex: 1.5, minWidth: 80,
renderCell: (p) => (
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%', height: '100%', overflow: 'hidden' }}>
<Typography variant="body2" color="text.secondary" sx={{ fontSize: { xs: '0.68rem', sm: '0.8rem' }, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{p.row.faction_name}</Typography>
</Box>
),
})
}
return cols
}

21
react-app/src/utils/format.js vendored Normal file
View File

@@ -0,0 +1,21 @@
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'
}

31
react-app/src/utils/history.js vendored Normal file
View File

@@ -0,0 +1,31 @@
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 }
}

12
react-app/src/utils/movers.js vendored Normal file
View File

@@ -0,0 +1,12 @@
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 }
}

18
react-app/src/utils/url.js vendored Normal file
View File

@@ -0,0 +1,18 @@
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)
}

View File

@@ -1,8 +1,31 @@
/// <reference types="vitest" />
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
plugins: [react({ jsxRuntime: 'automatic' })],
esbuild: {
jsx: 'automatic',
},
resolve: {
alias: [
{ find: /^react-transition-group\/TransitionGroupContext$/, replacement: 'react-transition-group/cjs/TransitionGroupContext.js' },
],
},
optimizeDeps: {
include: ['react-transition-group/TransitionGroupContext'],
},
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.js'],
css: false,
server: {
deps: {
inline: ['@mui/material', '@mui/x-data-grid'],
},
},
},
build: {
outDir: 'dist',
base: './',
@@ -17,4 +40,4 @@ export default defineConfig({
server: {
port: 9102,
},
})
})