feat(configurations): rank by intent using CPU benchmark scores
Rework /configurations so the highlighted "best deals" actually reflect
what they claim. The old queries had two concrete bugs — the D1 path
pre-filtered to the top-100 cheapest and dedupeed per-category inside
that biased sample, while the DuckDB fallback used `DISTINCT ON (cpu)
ORDER BY seen DESC` which kept a near-random row per CPU instead of
the cheapest. Neither path used the Geekbench scores that phase 2-3 of
the CPU enrichment work already populated.
Consolidate both paths behind a shared category module and swap the
five component-focused categories for six intent-focused ones:
best price/performance, most affordable (with a GB5 multicore floor to
exclude Atom-class chips), best €/core, best €/GB RAM, best €/TB NVMe,
and best €/TB bulk storage (SATA + HDD combined). Ranking happens in
SQL with ROW_NUMBER() partitioned on (cpu, ram_size, is_ecc, storage
shape), so variants of the same CPU can coexist when they genuinely
win different categories. Per-query failures no longer crash the page
loader; the DuckDB fallback takes over cleanly. A "snapshot updated"
timestamp and an optional GPU deep-link round out the page.
Spec at specs/configurations-rework.md captures the full rationale,
decisions, and open follow-ups (per-CPU and per-config landing pages
for SEO).
feat(servers): per-CPU landing pages with price history
Adds a stable, indexable page per Hetzner-listed CPU at /servers/cpu/[slug]
plus an alphabetical index at /servers/cpu/. Each detail page covers
the cheapest live listing, common configurations, a 90-day daily-min
price chart, and related CPUs in the same vendor — all rendered
server-side with edge caching (s-maxage=3600, stale-while-revalidate).
Slugs come from a deterministic helper (strips vendor / trademark /
dual-CPU prefixes, lowercase, hyphenate) so single- and dual-socket
variants of the same CPU collapse onto one page and the URL
roundtrips cleanly. Eligibility requires a live row plus a non-null
Geekbench 5 multicore score so we never publish an enrichment-gap page.
Each page emits BreadcrumbList plus a Product with AggregateOffer
(low/high price, offer count, EUR) so the listings are eligible for
rich results. The /servers/cpu index emits an ItemList of every CPU
page. The sitemap is extended with paramValues so super-sitemap picks
up every slug. /configurations gets a "Browse by CPU" card linking
into the new index.
If the data layer fails (e.g. local dev with stale D1 schema) the
detail route degrades to a clean 404 instead of crashing the loader.
17 vitest cases cover slugifyCpu, vendorOf, and displayCpuName.
Spec: specs/per-cpu-landing-pages.md.
feat(servers): per-CPU landing pages with price history
Adds a stable, indexable page per Hetzner-listed CPU at /servers/cpu/[slug]
plus an alphabetical index at /servers/cpu/. Each detail page covers
the cheapest live listing, common configurations, a 90-day daily-min
price chart, and related CPUs in the same vendor — all rendered
server-side with edge caching (s-maxage=3600, stale-while-revalidate).
Slugs come from a deterministic helper (strips vendor / trademark /
dual-CPU prefixes, lowercase, hyphenate) so single- and dual-socket
variants of the same CPU collapse onto one page and the URL
roundtrips cleanly. Eligibility requires a live row plus a non-null
Geekbench 5 multicore score so we never publish an enrichment-gap page.
Each page emits BreadcrumbList plus a Product with AggregateOffer
(low/high price, offer count, EUR) so the listings are eligible for
rich results. The /servers/cpu index emits an ItemList of every CPU
page. The sitemap is extended with paramValues so super-sitemap picks
up every slug. /configurations gets a "Browse by CPU" card linking
into the new index.
If the data layer fails (e.g. local dev with stale D1 schema) the
detail route degrades to a clean 404 instead of crashing the loader.
17 vitest cases cover slugifyCpu, vendorOf, and displayCpuName.
Spec: specs/per-cpu-landing-pages.md.
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.