refactor/component-tree-2 #3
2393
docs/superpowers/plans/2026-06-23-react-component-tree-refactor.md
Normal file
2393
docs/superpowers/plans/2026-06-23-react-component-tree-refactor.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 36–74). 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.
|
||||
3265
react-app/package-lock.json
generated
3265
react-app/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
61
react-app/src/components/FilterBar.jsx
Normal file
61
react-app/src/components/FilterBar.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
79
react-app/src/components/GraphModal.jsx
Normal file
79
react-app/src/components/GraphModal.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
30
react-app/src/components/MoversPanel.jsx
Normal file
30
react-app/src/components/MoversPanel.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
26
react-app/src/components/MoversSection.jsx
Normal file
26
react-app/src/components/MoversSection.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
41
react-app/src/components/PointsHistoryChart.jsx
Normal file
41
react-app/src/components/PointsHistoryChart.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
43
react-app/src/components/SizeCell.jsx
Normal file
43
react-app/src/components/SizeCell.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
45
react-app/src/components/UnitTable.jsx
Normal file
45
react-app/src/components/UnitTable.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
178
react-app/src/test/App.test.jsx
Normal file
178
react-app/src/test/App.test.jsx
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
72
react-app/src/test/FilterBar.test.jsx
Normal file
72
react-app/src/test/FilterBar.test.jsx
Normal 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()
|
||||
})
|
||||
})
|
||||
65
react-app/src/test/GraphModal.test.jsx
Normal file
65
react-app/src/test/GraphModal.test.jsx
Normal 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()
|
||||
})
|
||||
})
|
||||
71
react-app/src/test/MoversSection.test.jsx
Normal file
71
react-app/src/test/MoversSection.test.jsx
Normal 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)
|
||||
})
|
||||
})
|
||||
49
react-app/src/test/SizeCell.test.jsx
Normal file
49
react-app/src/test/SizeCell.test.jsx
Normal 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()
|
||||
})
|
||||
})
|
||||
59
react-app/src/test/UnitTable.test.jsx
Normal file
59
react-app/src/test/UnitTable.test.jsx
Normal 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
207
react-app/src/test/fixtures/data.json
vendored
Normal 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
31
react-app/src/test/setup.js
vendored
Normal 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,
|
||||
)
|
||||
}
|
||||
173
react-app/src/test/utils.test.js
Normal file
173
react-app/src/test/utils.test.js
Normal 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)
|
||||
})
|
||||
})
|
||||
80
react-app/src/utils/columns.jsx
Normal file
80
react-app/src/utils/columns.jsx
Normal 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
21
react-app/src/utils/format.js
vendored
Normal 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
31
react-app/src/utils/history.js
vendored
Normal 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
12
react-app/src/utils/movers.js
vendored
Normal 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
18
react-app/src/utils/url.js
vendored
Normal 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)
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user