refactor: rewrite App.jsx as orchestrator over new component tree
This commit is contained in:
@@ -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)
|
||||
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 data-testid="app-root" 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>
|
||||
)
|
||||
|
||||
164
react-app/src/test/App.test.jsx
Normal file
164
react-app/src/test/App.test.jsx
Normal file
@@ -0,0 +1,164 @@
|
||||
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('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.getByText('New Unit X'))
|
||||
// Click on the "New Unit X" row (no history)
|
||||
await user.click(screen.getByText('New Unit X'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/no historical data/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('movers cards reflect the filtered set and clicking a row opens the modal', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithTheme(<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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user