feat(statistics): tabbed sections, honest gaps, smoothing overlay
Rework /statistics from one eight-chart scroll into four tabs behind a
sticky left rail that hangs in the page gutter from 2xl up. Only the
active panel mounts, and each card builds its canvas when it scrolls
into view.
Missing days are no longer drawn as straight lines. Every series is
aligned onto a shared daily grid derived from the days the crawler
actually observed: counts zero-fill (nobody listed a server that day),
prices break (there is no price to plot), and a collection outage breaks
every series at once.
Price charts gain a 7-day centered moving average over a receded raw
line, toggled from the rail. The average is null-preserving, so it never
bridges a gap. Chart.js bezier tension drops to 0 — the moving average
is the honest version of what tension was faking.
Series colours come from a fixed CVD-checked palette indexed by position
instead of Math.random() per render, which was repainting every chart on
each data update.
Fix four defects in the key metrics along the way:
- Per-unit price minima were set by fixed-price listings, whose
nvme_drives holds the configurator's *menu* rather than one machine's
drives (33 TB across eight entries, identical under two CPUs). One
such row decided the whole day. Excluding them moves the NVMe minimum
from €1.14/TB to €6.30/TB and NVMe-vs-HDD from 1.1x to 6.2x.
- The trend arrow never moved: FontAwesomeIcon does not swap its SVG
when the icon prop changes, so the tile kept the down arrow it mounted
with while showing a red "+4.08% rising".
- "Lowest price" was min(cheapest AMD, cheapest Intel), which can never
return a listing whose cpu_vendor is neither ("Intel®", "2x").
- The price index cannot exceed 1.0 by construction, so a subtitle
reading "Values > 1.0 = higher prices" described an unreachable state.
Drop the AMD-vs-Intel tile (it divided two unrelated machines) and
replace "ECC Premium" with an absolute best €/GB, since the old delta
measured RAM capacity, not error correction.
Also collapses the 37 per-datacenter queries into one grouped query per
country, and streams each result into state as it lands rather than in
three blocking batches.
refactor(filter-spec): make the shared package authoritative for disk geometry
packages/filter-spec/src/constants.ts documents itself as the fix for constants
that 'were previously re-declared in each place that needed them' and ends
'Import from here instead.' Three files did; six did not, including the two
matchers the package was built for and the ServerFilter.svelte sliders its own
docstring names.
Now imported rather than redeclared:
- ServerFilter.svelte: dropped its six local disk constants and the three
inlined copies of diskSizeCeiling(); DISK_UNIT_GB replaces the hardcoded 500
in the six getFormattedDiskSize calls.
- api/shared/filter-query.ts: 12 '* 500' -> DISK_UNIT_GB, local cityPrefixes ->
CITY_PREFIXES.
- worker/alert-matching-sql.ts: 24 inline 500s -> ${DISK_UNIT_GB}, and the
hardcoded IN ('FSN','NBG','HEL') is now built from CITY_PREFIXES.
- api/frontend/stats.ts: the prefix CASE and WHERE are generated from
CITY_PREFIXES instead of three hand-written SQL literals.
- lib/filter.ts: the Hetzner URL builder rounds by DISK_UNIT_GB.
Both 'deliberately dependency-free' headers are corrected: they were justifying
the bypass, when the package resolves from both toolchains — which is its whole
purpose.
auction-data-transformer's NBG/FSN check is deliberately left alone: it maps
datacenter to country, so coupling it to CITY_PREFIXES would break the moment a
city is added in a new country.
Also removes the write-only _hasStoredFilter, whose declaration went with the
constants block.
Verified by the conformance harness (DuckDB vs SQLite, 20 servers x 28 filters)
and the golden snapshot, both unmoved — the constants are numerically identical,
so this is a sourcing change with no behaviour change. Full check/lint/test green
on both workspaces.