cars: car pokedex tracker (brands, models, US variants from RDW + fueleconomy.gov), import script, search/filter UI, model pages
Some checks failed
Deploy production / deploy (push) Has been cancelled

This commit is contained in:
2026-10-24 15:38:30 +00:00
parent cc4b10f239
commit e95b7da1e3
27 changed files with 21353 additions and 4 deletions

View File

@@ -24,7 +24,8 @@ export default defineConfig({
'books',
'tasks',
'groceries',
'home'
'home',
'cars'
],
migrations: { schema: 'drizzle', table: '__drizzle_migrations' },
verbose: true,

View File

@@ -0,0 +1,134 @@
CREATE EXTENSION IF NOT EXISTS pg_trgm WITH SCHEMA public;
--> statement-breakpoint
CREATE SCHEMA "cars";
--> statement-breakpoint
CREATE TABLE "cars"."brands" (
"id" serial PRIMARY KEY,
"slug" text NOT NULL,
"name" text NOT NULL,
"country" text,
"model_count" integer DEFAULT 0 NOT NULL,
"nl_count" integer DEFAULT 0 NOT NULL,
"first_year" smallint,
"last_year" smallint,
"sources" text[] DEFAULT '{}'::text[] NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "cars"."models" (
"id" serial PRIMARY KEY,
"dex" integer NOT NULL,
"brand_id" integer NOT NULL,
"slug" text NOT NULL,
"name" text NOT NULL,
"search" text NOT NULL,
"first_year" smallint,
"last_year" smallint,
"bodies" text[] DEFAULT '{}'::text[] NOT NULL,
"size_classes" text[] DEFAULT '{}'::text[] NOT NULL,
"fuels" text[] DEFAULT '{}'::text[] NOT NULL,
"drives" text[] DEFAULT '{}'::text[] NOT NULL,
"transmissions" text[] DEFAULT '{}'::text[] NOT NULL,
"engine_l_min" real,
"engine_l_max" real,
"cylinders_min" smallint,
"cylinders_max" smallint,
"turbo" boolean,
"power_kw_min" real,
"power_kw_max" real,
"l100_min" real,
"l100_max" real,
"co2_min" smallint,
"co2_max" smallint,
"mpg_min" real,
"mpg_max" real,
"range_km_max" smallint,
"seats" smallint,
"doors" smallint,
"mass_kg" smallint,
"nl_count" integer DEFAULT 0 NOT NULL,
"nl_rank" integer,
"nl_first_year" smallint,
"nl_last_year" smallint,
"nl_price" integer,
"nl_price_latest" integer,
"nl_price_latest_year" smallint,
"nl_years" jsonb DEFAULT '[]' NOT NULL,
"nl_names" jsonb DEFAULT '[]' NOT NULL,
"us_years" smallint[] DEFAULT '{}'::smallint[] NOT NULL,
"variant_count" integer DEFAULT 0 NOT NULL,
"sources" text[] DEFAULT '{}'::text[] NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "cars"."variants" (
"id" serial PRIMARY KEY,
"model_id" integer NOT NULL,
"source" text DEFAULT 'fueleconomy.gov' NOT NULL,
"source_id" integer,
"year" smallint NOT NULL,
"name" text NOT NULL,
"engine_l" real,
"cylinders" smallint,
"transmission" text,
"drive" text,
"fuel" text,
"size_class" text,
"mpg_city" real,
"mpg_hwy" real,
"mpg_comb" real,
"co2_gpm" smallint,
"range_mi" smallint,
"ev_motor" text,
"turbo" boolean DEFAULT false NOT NULL,
"supercharged" boolean DEFAULT false NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX "brands_slug_key" ON "cars"."brands" ("slug");--> statement-breakpoint
CREATE UNIQUE INDEX "models_dex_key" ON "cars"."models" ("dex");--> statement-breakpoint
CREATE UNIQUE INDEX "models_brand_slug_key" ON "cars"."models" ("brand_id","slug");--> statement-breakpoint
CREATE INDEX "models_nl_count_idx" ON "cars"."models" ("nl_count");--> statement-breakpoint
CREATE INDEX "models_search_trgm_idx" ON "cars"."models" USING gin ("search" public.gin_trgm_ops);--> statement-breakpoint
CREATE INDEX "variants_model_year_idx" ON "cars"."variants" ("model_id","year");--> statement-breakpoint
ALTER TABLE "cars"."models" ADD CONSTRAINT "models_brand_id_brands_id_fkey" FOREIGN KEY ("brand_id") REFERENCES "cars"."brands"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "cars"."variants" ADD CONSTRAINT "variants_model_id_models_id_fkey" FOREIGN KEY ("model_id") REFERENCES "cars"."models"("id") ON DELETE CASCADE;
--> statement-breakpoint
-- Hand-written: updated_at triggers, grants, GUI registration. Data comes from scripts/cars-import.ts.
DO $$
DECLARE t text;
BEGIN
FOREACH t IN ARRAY ARRAY['brands','models'] LOOP
EXECUTE format('CREATE TRIGGER touch_updated_at BEFORE UPDATE ON cars.%I FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at()', t);
END LOOP;
END $$;
--> statement-breakpoint
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
GRANT USAGE ON SCHEMA cars TO personal_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA cars TO personal_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA cars TO personal_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA cars GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA cars GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ro') THEN
GRANT USAGE ON SCHEMA cars TO personal_ro;
GRANT SELECT ON ALL TABLES IN SCHEMA cars TO personal_ro;
ALTER DEFAULT PRIVILEGES IN SCHEMA cars GRANT SELECT ON TABLES TO personal_ro;
END IF;
END $$;
--> statement-breakpoint
DO $$
BEGIN
IF to_regclass('meta.tables') IS NOT NULL THEN
INSERT INTO meta.tables (schema_name, table_name, slug, display_name, kind, editable, has_done, sort_order)
VALUES ('cars','brands','cars-brands','Car brands','core',false,false,1000),
('cars','models','cars-models','Car models','core',false,false,1000)
ON CONFLICT DO NOTHING;
IF has_function_privilege('meta.sync(integer)', 'EXECUTE') THEN
PERFORM meta.sync();
END IF;
END IF;
END $$;

File diff suppressed because it is too large Load Diff

View File

@@ -20,6 +20,7 @@
"preview:branch": "bash scripts/preview.sh",
"db:studio": "drizzle-kit studio",
"db:seed": "bun scripts/seed-catalog.ts",
"cars:import": "bun scripts/cars-import.ts",
"db:seed:cod": "bun scripts/seed-cod-catalog.ts",
"cs-prices:ingest": "bun scripts/ingest-cs-prices.ts",
"house:worker": "bun scripts/house-worker.ts",

1055
scripts/cars-import.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import { brandHue, monogram } from './shared.js';
let { name, size = 'md' }: { name: string; size?: 'sm' | 'md' | 'lg' } = $props();
const hue = $derived(brandHue(name));
</script>
<span class={['badge', size]} style:--hue={hue} aria-hidden="true">{monogram(name)}</span>
<style>
.badge {
display: inline-grid;
place-items: center;
flex-shrink: 0;
border-radius: 999px;
font-weight: 700;
letter-spacing: 0.02em;
color: hsl(var(--hue) 90% 88%);
background: radial-gradient(
circle at 30% 25%,
hsl(var(--hue) 55% 42%),
hsl(var(--hue) 60% 22%)
);
box-shadow:
inset 0 0 0 2px hsl(var(--hue) 60% 55% / 0.45),
0 2px 8px hsl(var(--hue) 60% 20% / 0.4);
}
.sm {
width: 1.9rem;
height: 1.9rem;
font-size: 0.7rem;
}
.md {
width: 2.6rem;
height: 2.6rem;
font-size: 0.85rem;
}
.lg {
width: 4.5rem;
height: 4.5rem;
font-size: 1.4rem;
}
</style>

125
src/lib/cars/CarCard.svelte Normal file
View File

@@ -0,0 +1,125 @@
<script lang="ts">
import BrandBadge from './BrandBadge.svelte';
import FuelChip from './FuelChip.svelte';
import {
bodyLabel,
dexLabel,
fmtCompact,
fmtEuro,
kwToHp,
years,
type CarCard
} from './shared.js';
let { car, current }: { car: CarCard; current: number } = $props();
const href = $derived(`/trackers/cars/${car.brandSlug}/${car.slug}`);
</script>
<a {href} class="card">
<div class="top">
<BrandBadge name={car.brandName} />
<span class="dex">{dexLabel(car.dex)}</span>
</div>
<div class="min-w-0">
<p class="brand">{car.brandName}</p>
<h3 class="name">{car.name}</h3>
<p class="meta">
{years(car.firstYear, car.lastYear, current) ?? '—'}{#if car.bodies[0]}
· {bodyLabel[car.bodies[0]] ?? car.bodies[0]}{/if}
</p>
</div>
<div class="chips">
{#each car.fuels.slice(0, 3) as fuel (fuel)}<FuelChip {fuel} />{/each}
</div>
<dl class="nums">
<div title="Registered in the Netherlands (RDW)">
<dt>🇳🇱 on the road</dt>
<dd>{car.nlCount ? fmtCompact(car.nlCount) : '—'}</dd>
</div>
<div title="Highest power across versions">
<dt>max power</dt>
<dd>{car.powerKwMax ? `${kwToHp(car.powerKwMax)} hp` : '—'}</dd>
</div>
<div title="Average list price of recently registered new cars in NL">
<dt>new price</dt>
<dd>{car.nlPriceLatest ? fmtEuro(car.nlPriceLatest) : '—'}</dd>
</div>
</dl>
</a>
<style>
.card {
display: flex;
flex-direction: column;
gap: 0.6rem;
height: 100%;
border: 1px solid var(--site-border);
border-radius: 0.9rem;
background: var(--site-surface);
padding: 0.9rem;
transition:
transform 0.15s,
border-color 0.15s,
background 0.15s;
}
.card:hover,
.card:focus-visible {
transform: translateY(-2px);
border-color: var(--site-muted);
background: var(--site-hover);
}
.top {
display: flex;
align-items: flex-start;
justify-content: space-between;
}
.dex {
font-family: ui-monospace, monospace;
font-size: 0.75rem;
color: var(--site-muted);
}
.brand {
font-size: 0.75rem;
color: var(--site-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.name {
font-size: 1.1rem;
font-weight: 600;
line-height: 1.25;
letter-spacing: -0.01em;
overflow-wrap: anywhere;
}
.meta {
margin-top: 0.1rem;
font-size: 0.78rem;
color: var(--site-muted);
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
min-height: 1.3rem;
}
.nums {
margin-top: auto;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.4rem;
border-top: 1px solid var(--site-border);
padding-top: 0.6rem;
}
.nums dt {
font-size: 0.62rem;
color: var(--site-muted);
white-space: nowrap;
}
.nums dd {
font-size: 0.82rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,63 @@
<script lang="ts">
import { fuelLabel } from './shared.js';
let { fuel, title }: { fuel: string; title?: string } = $props();
const icon: Record<string, string> = {
petrol: '⛽',
diesel: '🛢️',
hybrid: '🍃',
'plug-in hybrid': '🔌',
electric: '⚡',
lpg: '🔥',
cng: '💨',
hydrogen: '💧'
};
</script>
<span class="fuel" data-fuel={fuel} {title}
><span aria-hidden="true">{icon[fuel] ?? '•'}</span>{fuelLabel[fuel] ?? fuel}</span
>
<style>
.fuel {
--c: 220 10% 60%;
display: inline-flex;
align-items: center;
gap: 0.25rem;
border-radius: 999px;
padding: 0.05rem 0.5rem 0.05rem 0.4rem;
font-size: 0.72rem;
font-weight: 500;
white-space: nowrap;
color: hsl(var(--c));
background: hsl(var(--c) / 0.14);
box-shadow: inset 0 0 0 1px hsl(var(--c) / 0.3);
}
.fuel span {
font-size: 0.7rem;
}
[data-fuel='petrol'] {
--c: 8 85% 66%;
}
[data-fuel='diesel'] {
--c: 35 25% 68%;
}
[data-fuel='hybrid'] {
--c: 140 55% 58%;
}
[data-fuel='plug-in hybrid'] {
--c: 175 60% 52%;
}
[data-fuel='electric'] {
--c: 50 95% 60%;
}
[data-fuel='lpg'] {
--c: 280 60% 72%;
}
[data-fuel='cng'] {
--c: 200 30% 70%;
}
[data-fuel='hydrogen'] {
--c: 210 90% 66%;
}
</style>

167
src/lib/cars/shared.ts Normal file
View File

@@ -0,0 +1,167 @@
// Shared (client + server) labels and helpers for the car pokedex (/trackers/cars).
export const FUELS = [
'petrol',
'diesel',
'hybrid',
'plug-in hybrid',
'electric',
'lpg',
'cng',
'hydrogen'
] as const;
export const BODIES = [
'hatchback',
'sedan',
'wagon',
'suv',
'mpv',
'van',
'pickup',
'coupe',
'convertible',
'sports'
] as const;
export const DRIVES = ['fwd', 'rwd', 'awd', '4wd'] as const;
export const SORTS = {
dex: 'dex #',
popular: 'most common in NL',
name: 'name',
newest: 'newest',
oldest: 'oldest',
cheap: 'cheapest new (NL)',
power: 'most powerful',
efficient: 'most efficient'
} as const;
export type CarSort = keyof typeof SORTS;
export const fuelLabel: Record<string, string> = {
petrol: 'petrol',
diesel: 'diesel',
hybrid: 'hybrid',
'plug-in hybrid': 'plug-in',
electric: 'electric',
lpg: 'LPG',
cng: 'CNG',
hydrogen: 'hydrogen'
};
export const driveLabel: Record<string, string> = {
fwd: 'front-wheel drive',
rwd: 'rear-wheel drive',
awd: 'all-wheel drive',
'4wd': '4x4'
};
export const bodyLabel: Record<string, string> = {
hatchback: 'hatchback',
sedan: 'sedan',
wagon: 'estate / wagon',
suv: 'SUV',
mpv: 'MPV',
van: 'van',
pickup: 'pickup',
coupe: 'coupé',
convertible: 'convertible',
sports: 'two-seater'
};
/** What a newcomer should know about each fuel type, one line each. */
export const fuelHint: Record<string, string> = {
petrol: 'Cheapest to buy, simple, but fuel is expensive in NL.',
diesel: 'Frugal on long trips, but NL road tax is much higher and city bans are growing.',
hybrid: 'Petrol engine + small battery that charges itself. Great for city driving.',
'plug-in hybrid': 'Bigger battery you charge from a plug; short trips can be fully electric.',
electric:
'Battery only: cheapest per km and quiet, but you need somewhere to charge. NL road-tax discounts are shrinking.',
lpg: 'Runs on autogas (cheap fuel) via a conversion; extra road tax in NL.',
cng: 'Runs on compressed natural gas; very few fuel stations in NL.',
hydrogen: 'Fuel cell car; only a handful of hydrogen stations in NL.'
};
const FLAGS: Record<string, string> = {
Germany: '🇩🇪',
Japan: '🇯🇵',
'United States': '🇺🇸',
Italy: '🇮🇹',
France: '🇫🇷',
'United Kingdom': '🇬🇧',
Sweden: '🇸🇪',
'South Korea': '🇰🇷',
China: '🇨🇳',
'Czech Republic': '🇨🇿',
Spain: '🇪🇸',
Romania: '🇷🇴',
Netherlands: '🇳🇱',
Russia: '🇷🇺',
India: '🇮🇳',
Vietnam: '🇻🇳',
Malaysia: '🇲🇾',
Croatia: '🇭🇷'
};
export const flag = (country: string | null) => (country ? (FLAGS[country] ?? '') : '');
export const dexLabel = (dex: number) => `#${String(dex).padStart(4, '0')}`;
/** Two-letter monogram for a brand badge ("Mercedes-Benz" -> "MB", "Toyota" -> "TO"). */
export function monogram(name: string): string {
const words = name
.replace(/&/g, ' ')
.split(/[\s-]+/)
.filter((w) => /[A-Za-z0-9]/.test(w));
if (words.length >= 2) return (words[0][0] + words[1][0]).toUpperCase();
return name
.replace(/[^A-Za-z0-9]/g, '')
.slice(0, 2)
.toUpperCase();
}
/** Stable hue per brand so each badge keeps its colour. */
export function brandHue(name: string): number {
let h = 0;
for (const c of name) h = (h * 31 + c.charCodeAt(0)) % 360;
return h;
}
export const kwToHp = (kw: number) => Math.round(kw * 1.341);
export const mpgToL100 = (mpg: number) => 235.215 / mpg;
const compact = new Intl.NumberFormat('en-GB', { notation: 'compact', maximumFractionDigits: 1 });
const whole = new Intl.NumberFormat('en-GB');
const euro = new Intl.NumberFormat('nl-NL', {
style: 'currency',
currency: 'EUR',
maximumFractionDigits: 0
});
export const fmtCompact = (n: number) => compact.format(n);
export const fmtNumber = (n: number) => whole.format(n);
export const fmtEuro = (n: number) => euro.format(n);
export function range(
min: number | null,
max: number | null,
format: (n: number) => string = String
): string | null {
if (min == null && max == null) return null;
if (min == null || max == null || min === max) return format((min ?? max)!);
return `${format(min)}–${format(max)}`;
}
export const years = (first: number | null, last: number | null, current: number) =>
first == null
? null
: last == null || first === last
? String(first)
: `${first}–${last >= current ? 'now' : last}`;
export type CarCard = {
dex: number;
slug: string;
name: string;
brandSlug: string;
brandName: string;
firstYear: number | null;
lastYear: number | null;
fuels: string[];
bodies: string[];
nlCount: number;
nlPriceLatest: number | null;
powerKwMax: number | null;
};

View File

@@ -7,13 +7,13 @@ describe('trackers', () => {
visibleTrackers(true)
.map((t) => t.name)
.slice(0, 4)
).toEqual(['tasks', 'money', 'books', 'health']);
).toEqual(['tasks', 'money', 'books', 'cars']);
});
it('hides private trackers when logged out', () => {
const names = visibleTrackers(false).map((t) => t.name);
expect(names).not.toContain('tasks');
expect(names).not.toContain('health');
expect(names.slice(0, 2)).toEqual(['money', 'books']);
expect(names.slice(0, 3)).toEqual(['money', 'books', 'cars']);
});
it('uses short single-word names', () => {
for (const t of visibleTrackers(true)) expect(t.name).toMatch(/^[a-z]+$/);

View File

@@ -58,6 +58,13 @@ const allTrackers = [
external: false,
private: false
},
{
name: 'cars',
subtitle: 'a car pokedex: every brand and model, with Dutch and US data',
href: '/trackers/cars',
external: false,
private: false
},
{
name: 'health',
subtitle: 'private: body, workouts, sleep, meds and vaccinations',

View File

@@ -44,6 +44,11 @@ const publicPages: Record<string, { title: string; description: string }> = {
title: 'Books',
description: 'Stan Runge’s library: books being read, finished and on the to-read pile.'
},
'/trackers/cars': {
title: 'Car pokedex',
description:
'Search every car brand and model: specs, fuel types, Dutch registrations and US fuel economy.'
},
'/lists': { title: 'Lists', description: 'Lists collected and maintained by Stan Runge.' },
'/projects/game-tracker': {
title: 'Game Tracker',

198
src/lib/server/cars.ts Normal file
View File

@@ -0,0 +1,198 @@
// Car pokedex queries (/trackers/cars). Public, read-only; data is written only by
// scripts/cars-import.ts.
import { db } from '#lib/server/db/index.js';
import { carBrands, carModels, carVariants } from '#lib/server/db/schema/index.js';
import { and, arrayContains, asc, desc, eq, gt, gte, ilike, lte, sql, type SQL } from 'drizzle-orm';
import { BODIES, DRIVES, FUELS, SORTS, type CarCard, type CarSort } from '#lib/cars/shared.js';
export const PAGE_SIZE = 60;
export type CarFilters = {
q: string;
brand: string;
fuel: string;
body: string;
drive: string;
market: '' | 'nl' | 'us';
from: number | null;
to: number | null;
sort: CarSort;
page: number;
};
const pick = <T extends string>(value: string | null, allowed: readonly T[]): T | '' =>
allowed.includes(value as T) ? (value as T) : '';
const year = (value: string | null) => {
const n = Number(value);
return value && Number.isInteger(n) && n >= 1880 && n <= 2100 ? n : null;
};
export function parseFilters(params: URLSearchParams): CarFilters {
const sort = params.get('sort');
return {
q: (params.get('q') ?? '').trim().slice(0, 80),
brand: (params.get('brand') ?? '').replace(/[^a-z0-9-]/g, '').slice(0, 60),
fuel: pick(params.get('fuel'), FUELS),
body: pick(params.get('body'), BODIES),
drive: pick(params.get('drive'), DRIVES),
market: pick(params.get('market'), ['nl', 'us'] as const),
from: year(params.get('from')),
to: year(params.get('to')),
sort: sort && sort in SORTS ? (sort as CarSort) : 'dex',
page: Math.min(Math.max(1, Math.floor(Number(params.get('page')) || 1)), 1000)
};
}
const cardColumns = {
dex: carModels.dex,
slug: carModels.slug,
name: carModels.name,
brandSlug: carBrands.slug,
brandName: carBrands.name,
firstYear: carModels.firstYear,
lastYear: carModels.lastYear,
fuels: carModels.fuels,
bodies: carModels.bodies,
nlCount: carModels.nlCount,
nlPriceLatest: carModels.nlPriceLatest,
powerKwMax: carModels.powerKwMax
};
const escapeLike = (s: string) => s.replace(/[\\%_]/g, (c) => `\\${c}`);
export async function searchCars(f: CarFilters): Promise<{ cars: CarCard[]; total: number }> {
const where: SQL[] = [];
for (const word of f.q.toLowerCase().split(/\s+/).filter(Boolean).slice(0, 6))
where.push(ilike(carModels.search, `%${escapeLike(word)}%`));
if (f.brand) where.push(eq(carBrands.slug, f.brand));
if (f.fuel) where.push(arrayContains(carModels.fuels, [f.fuel]));
if (f.body) where.push(arrayContains(carModels.bodies, [f.body]));
if (f.drive) where.push(arrayContains(carModels.drives, [f.drive]));
if (f.market === 'nl') where.push(gt(carModels.nlCount, 0));
if (f.market === 'us') where.push(gt(carModels.variantCount, 0));
if (f.from) where.push(gte(carModels.lastYear, f.from));
if (f.to) where.push(lte(carModels.firstYear, f.to));
const order = {
dex: [asc(carModels.dex)],
popular: [desc(carModels.nlCount), asc(carModels.dex)],
name: [asc(carModels.name), asc(carBrands.name)],
newest: [desc(carModels.lastYear), desc(carModels.firstYear), desc(carModels.nlCount)],
oldest: [asc(carModels.firstYear), asc(carModels.dex)],
cheap: [sql`${carModels.nlPriceLatest} asc nulls last`, desc(carModels.nlCount)],
power: [sql`${carModels.powerKwMax} desc nulls last`, asc(carModels.dex)],
efficient: [sql`${carModels.co2Min} asc nulls last`, desc(carModels.nlCount)]
}[f.sort];
const condition = where.length ? and(...where) : undefined;
const [cars, [{ total }]] = await Promise.all([
db
.select(cardColumns)
.from(carModels)
.innerJoin(carBrands, eq(carBrands.id, carModels.brandId))
.where(condition)
.orderBy(...order)
.limit(PAGE_SIZE)
.offset((f.page - 1) * PAGE_SIZE),
db
.select({ total: sql<number>`count(*)::int` })
.from(carModels)
.innerJoin(carBrands, eq(carBrands.id, carModels.brandId))
.where(condition)
]);
return { cars, total };
}
type Overview = {
brands: { slug: string; name: string; models: number }[];
stats: { brands: number; models: number; variants: number; nlCars: number };
};
let overviewCache: { at: number; value: Overview } | null = null;
/** Brand list for the filter and headline counts; cached for 10 minutes. */
export async function carOverview(): Promise<Overview> {
if (overviewCache && Date.now() - overviewCache.at < 600_000) return overviewCache.value;
const [brands, [counts]] = await Promise.all([
db
.select({ slug: carBrands.slug, name: carBrands.name, models: carBrands.modelCount })
.from(carBrands)
.orderBy(asc(carBrands.name)),
db
.select({
models: sql<number>`count(*)::int`,
variants: sql<number>`coalesce(sum(${carModels.variantCount}), 0)::int`,
nlCars: sql<number>`coalesce(sum(${carModels.nlCount}), 0)::bigint`
})
.from(carModels)
]);
const value = {
brands,
stats: {
brands: brands.length,
models: counts.models,
variants: counts.variants,
nlCars: Number(counts.nlCars)
}
};
overviewCache = { at: Date.now(), value };
return value;
}
export async function getBrand(slug: string) {
const [brand] = await db.select().from(carBrands).where(eq(carBrands.slug, slug)).limit(1);
if (!brand) return null;
const models = await db
.select(cardColumns)
.from(carModels)
.innerJoin(carBrands, eq(carBrands.id, carModels.brandId))
.where(eq(carModels.brandId, brand.id))
.orderBy(desc(carModels.nlCount), desc(carModels.variantCount), asc(carModels.name));
return { brand, models };
}
export async function getModel(brandSlug: string, modelSlug: string) {
const [row] = await db
.select({ model: carModels, brand: carBrands })
.from(carModels)
.innerJoin(carBrands, eq(carBrands.id, carModels.brandId))
.where(and(eq(carBrands.slug, brandSlug), eq(carModels.slug, modelSlug)))
.limit(1);
if (!row) return null;
const [variants, neighbours] = await Promise.all([
db
.select()
.from(carVariants)
.where(eq(carVariants.modelId, row.model.id))
.orderBy(desc(carVariants.year), asc(carVariants.name), asc(carVariants.engineL)),
db
.select({
dex: carModels.dex,
slug: carModels.slug,
name: carModels.name,
brandSlug: carBrands.slug
})
.from(carModels)
.innerJoin(carBrands, eq(carBrands.id, carModels.brandId))
.where(
sql`${carModels.dex} in (
(select max(dex) from cars.models where dex < ${row.model.dex}),
(select min(dex) from cars.models where dex > ${row.model.dex}))`
)
]);
return {
...row,
variants,
prev: neighbours.find((n) => n.dex < row.model.dex) ?? null,
next: neighbours.find((n) => n.dex > row.model.dex) ?? null
};
}
/** A random model, favouring cars people actually drive (in NL or EPA-tested). */
export async function randomCar() {
const [row] = await db
.select({ slug: carModels.slug, brandSlug: carBrands.slug })
.from(carModels)
.innerJoin(carBrands, eq(carBrands.id, carModels.brandId))
.where(sql`${carModels.nlCount} >= 100 or ${carModels.variantCount} >= 5`)
.orderBy(sql`random()`)
.limit(1);
return row ?? null;
}

View File

@@ -0,0 +1,137 @@
import { sql } from 'drizzle-orm';
import {
boolean,
index,
integer,
jsonb,
real,
smallint,
text,
uniqueIndex
} from 'drizzle-orm/pg-core';
import { carsSchema } from './schemas';
import { id, timestamps } from './helpers';
// Public car "pokedex" (/trackers/cars). Read-only catalogue built from open data by
// scripts/cars-import.ts (re-runnable; upserts brands/models by slug so Dex numbers stay
// stable, and replaces variants). Sources:
// - fueleconomy.gov vehicles.csv (US EPA, 1984-now): one `variants` row per EPA test car.
// - RDW open data (opendata.rdw.nl, Gekentekende voertuigen + brandstof): per make/model
// aggregates of passenger cars registered in NL (count, years, list price, mass, body),
// plus fuel/power/consumption read from one sample registration per model-year.
// Unknown stays null; nothing is estimated.
/** Shape of `models.nl_years`: registrations per first-registration year. */
export type CarNlYear = { y: number; n: number; price: number | null };
/** Shape of `models.nl_names`: RDW trade names (handelsbenaming) folded into this model. */
export type CarNlName = { name: string; n: number };
export const carBrands = carsSchema.table(
'brands',
{
id: id(),
slug: text('slug').notNull(),
name: text('name').notNull(),
country: text('country'),
modelCount: integer('model_count').notNull().default(0),
nlCount: integer('nl_count').notNull().default(0),
firstYear: smallint('first_year'),
lastYear: smallint('last_year'),
sources: text('sources').array().notNull().default([]),
...timestamps
},
(t) => [uniqueIndex('brands_slug_key').on(t.slug)]
);
export const carModels = carsSchema.table(
'models',
{
id: id(),
dex: integer('dex').notNull(),
brandId: integer('brand_id')
.notNull()
.references(() => carBrands.id, { onDelete: 'cascade' }),
slug: text('slug').notNull(),
name: text('name').notNull(),
/** Lowercase brand + name + trade names, for ILIKE / trigram search. */
search: text('search').notNull(),
firstYear: smallint('first_year'),
lastYear: smallint('last_year'),
/** Consumer body types: hatchback, sedan, wagon, suv, mpv, van, pickup, coupe, convertible, sports. */
bodies: text('bodies').array().notNull().default([]),
/** US EPA size classes (e.g. "Compact Cars", "Small SUV"). */
sizeClasses: text('size_classes').array().notNull().default([]),
/** petrol, diesel, hybrid, plug-in hybrid, electric, lpg, cng, hydrogen. */
fuels: text('fuels').array().notNull().default([]),
/** fwd, rwd, awd, 4wd. */
drives: text('drives').array().notNull().default([]),
transmissions: text('transmissions').array().notNull().default([]),
engineLMin: real('engine_l_min'),
engineLMax: real('engine_l_max'),
cylindersMin: smallint('cylinders_min'),
cylindersMax: smallint('cylinders_max'),
turbo: boolean('turbo'),
powerKwMin: real('power_kw_min'),
powerKwMax: real('power_kw_max'),
/** Combined consumption, litres per 100 km (RDW type approval, NEDC or WLTP). */
l100Min: real('l100_min'),
l100Max: real('l100_max'),
co2Min: smallint('co2_min'), // g/km (RDW)
co2Max: smallint('co2_max'),
mpgMin: real('mpg_min'), // combined (EPA; MPGe for EVs)
mpgMax: real('mpg_max'),
rangeKmMax: smallint('range_km_max'), // EV range (EPA)
seats: smallint('seats'),
doors: smallint('doors'),
massKg: smallint('mass_kg'), // NL avg kerb weight (massa rijklaar)
nlCount: integer('nl_count').notNull().default(0),
nlRank: integer('nl_rank'),
nlFirstYear: smallint('nl_first_year'),
nlLastYear: smallint('nl_last_year'),
nlPrice: integer('nl_price'), // avg catalogue price (EUR, incl. taxes) over all NL registrations
nlPriceLatest: integer('nl_price_latest'), // avg catalogue price in the latest year with data
nlPriceLatestYear: smallint('nl_price_latest_year'),
nlYears: jsonb('nl_years').$type<CarNlYear[]>().notNull().default([]),
nlNames: jsonb('nl_names').$type<CarNlName[]>().notNull().default([]),
usYears: smallint('us_years').array().notNull().default([]),
variantCount: integer('variant_count').notNull().default(0),
sources: text('sources').array().notNull().default([]),
...timestamps
},
(t) => [
uniqueIndex('models_dex_key').on(t.dex),
uniqueIndex('models_brand_slug_key').on(t.brandId, t.slug),
index('models_nl_count_idx').on(t.nlCount),
index('models_search_trgm_idx').using('gin', sql`${t.search} public.gin_trgm_ops`)
]
);
/** One row per fueleconomy.gov test vehicle (model-year x engine x transmission x drive). */
export const carVariants = carsSchema.table(
'variants',
{
id: id(),
modelId: integer('model_id')
.notNull()
.references(() => carModels.id, { onDelete: 'cascade' }),
source: text('source').notNull().default('fueleconomy.gov'),
sourceId: integer('source_id'),
year: smallint('year').notNull(),
name: text('name').notNull(),
engineL: real('engine_l'),
cylinders: smallint('cylinders'),
transmission: text('transmission'),
drive: text('drive'),
fuel: text('fuel'),
sizeClass: text('size_class'),
mpgCity: real('mpg_city'),
mpgHwy: real('mpg_hwy'),
mpgComb: real('mpg_comb'),
co2Gpm: smallint('co2_gpm'), // tailpipe g/mile
rangeMi: smallint('range_mi'),
evMotor: text('ev_motor'),
turbo: boolean('turbo').notNull().default(false),
supercharged: boolean('supercharged').notNull().default(false)
},
(t) => [index('variants_model_year_idx').on(t.modelId, t.year)]
);

View File

@@ -17,3 +17,4 @@ export * from './books';
export * from './tasks';
export * from './groceries';
export * from './home';
export * from './cars';

View File

@@ -17,3 +17,4 @@ export const booksSchema = pgSchema('books');
export const tasksSchema = pgSchema('tasks');
export const groceriesSchema = pgSchema('groceries');
export const homeSchema = pgSchema('home');
export const carsSchema = pgSchema('cars');

View File

@@ -39,6 +39,8 @@ describe('owner gate', () => {
expect(isOwnerWritePath('/trackers/books/12')).toBe(true);
expect(isOwnerWritePath('/trackers/books')).toBe(true);
expect(isOwnerWritePath('/trackers/health/import')).toBe(true);
expect(isOwnerWritePath('/trackers/cars/ford/mustang')).toBe(true);
expect(isOwnerOnlyPath('/trackers/cars/ford/mustang')).toBe(false);
expect(isOwnerWritePath('/trackers/bookshelf')).toBe(false);
expect(isOwnerWritePath('/lists/books')).toBe(false);
});

View File

@@ -45,7 +45,8 @@ export const ownerWritePrefixes = [
'/trackers/tasks',
'/trackers/groceries',
'/trackers/houses',
'/trackers/books'
'/trackers/books',
'/trackers/cars'
] as const;
const normalise = (pathname: string) => pathname.toLowerCase().replace(/\/+$/, '');

View File

@@ -19,6 +19,7 @@ export function getTitle(path: string, environment: SiteEnvironment = null): str
'/projects': 'Projects',
'/trackers': 'Trackers',
'/trackers/books': 'Books',
'/trackers/cars': 'Cars',
'/blog': 'Blog',
'/lists': 'Lists',
'/login': 'Sign in',

View File

@@ -14,6 +14,7 @@ const staticPaths = [
'/blog',
'/lists',
'/trackers/books',
'/trackers/cars',
'/projects/game-tracker',
'/projects/game-tracker/pokemon',
'/projects/game-tracker/osu',

View File

@@ -0,0 +1,20 @@
import { carOverview, PAGE_SIZE, parseFilters, searchCars } from '#lib/server/cars.js';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ url }) => {
const filters = parseFilters(url.searchParams);
const [{ cars, total }, overview] = await Promise.all([searchCars(filters), carOverview()]);
return {
filters,
cars,
total,
pageSize: PAGE_SIZE,
brands: overview.brands,
stats: overview.stats,
currentYear: new Date().getFullYear(),
seo: {
title: 'Car pokedex',
description: `Search ${overview.stats.models.toLocaleString('en-GB')} car models from ${overview.stats.brands} brands, with Dutch registration data and US fuel economy specs.`
}
};
};

View File

@@ -0,0 +1,325 @@
<script lang="ts">
import { page } from '$app/state';
import CarCard from '#lib/cars/CarCard.svelte';
import {
BODIES,
bodyLabel,
DRIVES,
driveLabel,
FUELS,
fuelLabel,
fmtCompact,
fmtNumber,
SORTS
} from '#lib/cars/shared.js';
import { Input } from '#lib/components/ui/input/index.js';
import { NativeSelect } from '#lib/components/ui/native-select/index.js';
import Search from 'lucide-svelte/icons/search';
import Shuffle from 'lucide-svelte/icons/shuffle';
import X from 'lucide-svelte/icons/x';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
const f = $derived(data.filters);
const pages = $derived(Math.max(1, Math.ceil(data.total / data.pageSize)));
const filtering = $derived(
!!(f.q || f.brand || f.fuel || f.body || f.drive || f.market || f.from || f.to)
);
let form: HTMLFormElement | undefined = $state();
let timer: ReturnType<typeof setTimeout> | undefined;
const submit = () => {
clearTimeout(timer);
form?.requestSubmit();
};
const submitSoon = () => {
clearTimeout(timer);
timer = setTimeout(submit, 350);
};
function withParams(changes: Record<string, string | number | null>) {
const merged: Record<string, string> = Object.fromEntries(page.url.searchParams);
if (!('page' in changes)) delete merged.page;
for (const [key, value] of Object.entries(changes)) {
if (value === null || value === '') delete merged[key];
else merged[key] = String(value);
}
const qs = new URLSearchParams(merged).toString();
return `/trackers/cars${qs ? `?${qs}` : ''}`;
}
const quick = [
{ label: '🔥 most common in NL', params: { sort: 'popular', market: 'nl' } },
{ label: '⚡ electric', params: { fuel: 'electric', sort: 'popular' } },
{ label: '🍃 hybrids', params: { fuel: 'hybrid', sort: 'popular' } },
{ label: '🏎️ coupés', params: { body: 'coupe', sort: 'popular' } },
{ label: '💪 most powerful', params: { sort: 'power' } },
{ label: '🐎 Mustang', params: { q: 'mustang' } }
];
</script>
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
<header class="mb-8 flex flex-wrap items-end justify-between gap-4">
<div>
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">cars</h1>
<p class="mt-3 max-w-2xl text-[var(--site-muted)]">
A car pokedex: every brand and model sold as a regular production car that I could find in
Dutch registrations or US fuel-economy tests. Tap one to learn what it is.
</p>
</div>
<a href="/trackers/cars/random" class="pill" data-sveltekit-preload-data="off"
><Shuffle size={15} /> random car</a
>
</header>
<dl class="stats mb-8">
<div>
<dt>brands</dt>
<dd>{fmtNumber(data.stats.brands)}</dd>
</div>
<div>
<dt>models</dt>
<dd>{fmtNumber(data.stats.models)}</dd>
</div>
<div>
<dt>US versions</dt>
<dd>{fmtNumber(data.stats.variants)}</dd>
</div>
<div>
<dt>cars on Dutch roads</dt>
<dd>{fmtCompact(data.stats.nlCars)}</dd>
</div>
</dl>
<form
bind:this={form}
method="GET"
action="/trackers/cars"
class="filters"
data-sveltekit-keepfocus
data-sveltekit-noscroll
data-sveltekit-replacestate
>
<label class="search">
<Search size={16} aria-hidden="true" />
<Input
type="search"
name="q"
value={f.q}
oninput={submitSoon}
placeholder="Search 'mustang', 'yaris', 'golf gti'…"
aria-label="Search cars"
autocomplete="off"
class="border-0 bg-transparent pl-0 shadow-none focus-visible:ring-0"
/>
</label>
<div class="selects">
<NativeSelect name="brand" aria-label="Brand" value={f.brand} onchange={submit}>
<option value="">all brands</option>
{#each data.brands as b (b.slug)}<option value={b.slug}>{b.name} ({b.models})</option
>{/each}
</NativeSelect>
<NativeSelect name="fuel" aria-label="Fuel" value={f.fuel} onchange={submit}>
<option value="">any fuel</option>
{#each FUELS as x (x)}<option value={x}>{fuelLabel[x]}</option>{/each}
</NativeSelect>
<NativeSelect name="body" aria-label="Body type" value={f.body} onchange={submit}>
<option value="">any body</option>
{#each BODIES as x (x)}<option value={x}>{bodyLabel[x]}</option>{/each}
</NativeSelect>
<NativeSelect name="drive" aria-label="Drive" value={f.drive} onchange={submit}>
<option value="">any drive</option>
{#each DRIVES as x (x)}<option value={x}>{driveLabel[x]}</option>{/each}
</NativeSelect>
<NativeSelect name="market" aria-label="Market" value={f.market} onchange={submit}>
<option value="">NL + US</option>
<option value="nl">registered in NL</option>
<option value="us">sold in the US</option>
</NativeSelect>
<span class="years">
<Input
type="number"
name="from"
value={f.from ?? ''}
min="1900"
max={data.currentYear}
placeholder="from"
aria-label="Built from year"
onchange={submit}
class="w-20"
/>
<span aria-hidden="true">–</span>
<Input
type="number"
name="to"
value={f.to ?? ''}
min="1900"
max={data.currentYear}
placeholder="to"
aria-label="Built up to year"
onchange={submit}
class="w-20"
/>
</span>
<NativeSelect name="sort" aria-label="Sort by" value={f.sort} onchange={submit}>
{#each Object.entries(SORTS) as [key, label] (key)}<option value={key}>{label}</option
>{/each}
</NativeSelect>
<noscript><button type="submit" class="pill">search</button></noscript>
</div>
</form>
<div class="mb-6 flex flex-wrap items-center gap-2">
{#each quick as chip (chip.label)}
<a href={withParams({ ...chip.params })} class="quick">{chip.label}</a>
{/each}
{#if filtering}
<a href="/trackers/cars" class="quick clear"><X size={13} /> clear filters</a>
{/if}
</div>
<p class="mb-4 text-sm text-[var(--site-muted)]" aria-live="polite">
{fmtNumber(data.total)}
{data.total === 1 ? 'model' : 'models'}{#if pages > 1}
· page {f.page} of {pages}{/if}
</p>
{#if data.cars.length}
<ul class="grid">
{#each data.cars as car (car.dex)}
<li><CarCard {car} current={data.currentYear} /></li>
{/each}
</ul>
{:else}
<div class="empty">
<p class="text-lg font-medium">No cars match that.</p>
<p class="mt-1 text-sm text-[var(--site-muted)]">
Try fewer words or <a href="/trackers/cars" class="underline">clear the filters</a>.
</p>
</div>
{/if}
{#if pages > 1}
<nav class="pager" aria-label="Pages">
{#if f.page > 1}<a href={withParams({ page: f.page - 1 })} class="pill">← prev</a>{/if}
<span class="text-sm text-[var(--site-muted)] tabular-nums">{f.page} / {pages}</span>
{#if f.page < pages}<a href={withParams({ page: f.page + 1 })} class="pill">next →</a>{/if}
</nav>
{/if}
<footer class="mt-12 text-xs leading-relaxed text-[var(--site-muted)]">
Data: <a href="https://opendata.rdw.nl" class="underline">RDW open data</a> (Dutch vehicle
register, CC0) and
<a href="https://www.fueleconomy.gov/feg/download.shtml" class="underline">fueleconomy.gov</a>
(US EPA/DOE). Models are grouped from registered trade names, so a few odd splits remain. Prices are
average catalogue prices of new cars when first registered in NL. Blank means unknown.
</footer>
</div>
<style>
.stats {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
@media (min-width: 768px) {
.stats {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
.stats > div {
border: 1px solid var(--site-border);
border-radius: 0.75rem;
background: var(--site-surface);
padding: 0.9rem 1.1rem;
}
.stats dt {
font-size: 0.8rem;
color: var(--site-muted);
}
.stats dd {
margin-top: 0.15rem;
font-size: 1.75rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
letter-spacing: -0.02em;
}
.filters {
display: flex;
flex-direction: column;
gap: 0.6rem;
margin-bottom: 0.9rem;
}
.search {
display: flex;
align-items: center;
gap: 0.5rem;
border: 1px solid var(--site-border);
border-radius: 0.5rem;
padding: 0 0.75rem;
color: var(--site-muted);
background: var(--site-surface);
}
.search:focus-within {
border-color: var(--site-muted);
}
.selects {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.selects :global(select) {
max-width: 12rem;
}
.years {
display: inline-flex;
align-items: center;
gap: 0.3rem;
color: var(--site-muted);
}
.pill,
.quick {
display: inline-flex;
align-items: center;
gap: 0.35rem;
border: 1px solid var(--site-border);
border-radius: 999px;
padding: 0.35rem 0.85rem;
font-size: 0.85rem;
font-weight: 500;
background: var(--site-surface);
transition: background 0.15s;
}
.quick {
padding: 0.2rem 0.7rem;
font-size: 0.8rem;
font-weight: 400;
}
.pill:hover,
.quick:hover {
background: var(--site-hover);
}
.clear {
color: var(--site-muted);
}
.grid {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 14.5rem), 1fr));
}
.empty {
border: 1px dashed var(--site-border);
border-radius: 0.9rem;
padding: 3rem 1rem;
text-align: center;
}
.pager {
margin-top: 2rem;
display: flex;
justify-content: center;
align-items: center;
gap: 1rem;
}
</style>

View File

@@ -0,0 +1,27 @@
import { error } from '@sveltejs/kit';
import { getBrand } from '#lib/server/cars.js';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params }) => {
const found = await getBrand(params.brand);
if (!found) error(404, 'No such brand in the car pokedex');
const { brand, models } = found;
return {
brand: {
slug: brand.slug,
name: brand.name,
country: brand.country,
modelCount: brand.modelCount,
nlCount: brand.nlCount,
firstYear: brand.firstYear,
lastYear: brand.lastYear,
sources: brand.sources
},
models,
currentYear: new Date().getFullYear(),
seo: {
title: `${brand.name} cars`,
description: `All ${brand.modelCount} ${brand.name} models in Stan's car pokedex${brand.country ? ` (${brand.country})` : ''}, with Dutch registrations and specs.`
}
};
};

View File

@@ -0,0 +1,53 @@
<script lang="ts">
import BrandBadge from '#lib/cars/BrandBadge.svelte';
import CarCard from '#lib/cars/CarCard.svelte';
import { flag, fmtNumber, years } from '#lib/cars/shared.js';
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
const b = $derived(data.brand);
</script>
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
<a href="/trackers/cars" class="back"><ArrowLeft size={15} /> all cars</a>
<header class="mt-6 mb-8 flex items-center gap-5">
<BrandBadge name={b.name} size="lg" />
<div>
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">{b.name}</h1>
<p class="mt-2 text-[var(--site-muted)]">
{#if b.country}{flag(b.country)} {b.country} ·
{/if}{fmtNumber(b.modelCount)} models{#if b.firstYear}
· {years(b.firstYear, b.lastYear, data.currentYear)}{/if}{#if b.nlCount}
· {fmtNumber(b.nlCount)} on Dutch roads{/if}
</p>
</div>
</header>
<ul class="grid">
{#each data.models as car (car.dex)}
<li><CarCard {car} current={data.currentYear} /></li>
{/each}
</ul>
<p class="mt-10 text-xs text-[var(--site-muted)]">
Sorted by how many are registered in NL. Sources: {b.sources.join(', ')}.
</p>
</div>
<style>
.back {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.85rem;
color: var(--site-muted);
}
.back:hover {
color: var(--site-text);
}
.grid {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 14.5rem), 1fr));
}
</style>

View File

@@ -0,0 +1,46 @@
import { error } from '@sveltejs/kit';
import { getModel } from '#lib/server/cars.js';
import type { carModels, carVariants } from '#lib/server/db/schema/index.js';
import type { PageServerLoad } from './$types';
type Model = typeof carModels.$inferSelect;
type Variant = typeof carVariants.$inferSelect;
// Drop internal ids and the search blob before the row goes to the browser.
function publicCar(m: Model) {
const out: Partial<Model> = { ...m };
delete out.id;
delete out.brandId;
delete out.search;
delete out.createdAt;
delete out.updatedAt;
return {
...(out as Omit<Model, 'id' | 'brandId' | 'search' | 'createdAt' | 'updatedAt'>),
updatedAt: m.updatedAt?.toISOString() ?? null
};
}
function publicVariant(v: Variant) {
const out: Partial<Variant> = { ...v };
delete out.id;
delete out.modelId;
delete out.source;
return out as Omit<Variant, 'id' | 'modelId' | 'source'>;
}
export const load: PageServerLoad = async ({ params }) => {
const found = await getModel(params.brand, params.model);
if (!found) error(404, 'No such car in the pokedex');
const { model, brand, variants, prev, next } = found;
return {
car: publicCar(model),
brand: { slug: brand.slug, name: brand.name, country: brand.country },
variants: variants.map(publicVariant),
prev,
next,
currentYear: new Date().getFullYear(),
seo: {
title: `${brand.name} ${model.name}`,
description: `${brand.name} ${model.name}${model.firstYear ? ` (${model.firstYear}${model.lastYear && model.lastYear !== model.firstYear ? `–${model.lastYear}` : ''})` : ''}: specs, fuel types, Dutch registrations and US fuel economy.`
}
};
};

View File

@@ -0,0 +1,576 @@
<script lang="ts">
import BrandBadge from '#lib/cars/BrandBadge.svelte';
import FuelChip from '#lib/cars/FuelChip.svelte';
import {
bodyLabel,
dexLabel,
driveLabel,
flag,
fmtEuro,
fmtNumber,
fuelHint,
kwToHp,
mpgToL100,
range,
years
} from '#lib/cars/shared.js';
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
import ChevronLeft from 'lucide-svelte/icons/chevron-left';
import ChevronRight from 'lucide-svelte/icons/chevron-right';
import Shuffle from 'lucide-svelte/icons/shuffle';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
const car = $derived(data.car);
const variants = $derived(data.variants);
const clamp = (n: number) => Math.max(0.03, Math.min(1, n));
// Pokedex-style "base stats": each bar is scaled against the whole car world, not this model.
const baseStats = $derived(
[
car.powerKwMax != null && {
label: 'power',
value: `${kwToHp(car.powerKwMax)} hp`,
hint: 'top version',
score: clamp(kwToHp(car.powerKwMax) / 600),
hue: 8
},
car.co2Min != null && {
label: 'efficiency',
value: car.co2Min === 0 ? '0 g CO₂/km' : `${car.co2Min} g CO₂/km`,
hint: 'cleanest version',
score: clamp(1 - car.co2Min / 300),
hue: 140
},
car.nlCount > 0 && {
label: 'popularity',
value: `${fmtNumber(car.nlCount)} in NL`,
hint: car.nlRank ? `#${car.nlRank} most common` : '',
score: clamp(Math.log10(car.nlCount) / Math.log10(400_000)),
hue: 210
},
car.nlPriceLatest != null && {
label: 'price',
value: fmtEuro(car.nlPriceLatest),
hint: `new, ${car.nlPriceLatestYear}`,
score: clamp(car.nlPriceLatest / 150_000),
hue: 45
},
car.massKg != null && {
label: 'weight',
value: `${fmtNumber(car.massKg)} kg`,
hint: 'average, ready to drive',
score: clamp((car.massKg - 500) / 2500),
hue: 280
}
].filter((s) => !!s)
);
const nlMax = $derived(Math.max(1, ...car.nlYears.map((y) => y.n)));
const nlYears = $derived.by(() => {
// Fill gaps so the chart has one bar per year.
if (!car.nlYears.length) return [];
const byYear = new Map(car.nlYears.map((y) => [y.y, y]));
const first = car.nlYears[0].y;
const last = car.nlYears.at(-1)!.y;
return Array.from(
{ length: last - first + 1 },
(_, i) => byYear.get(first + i) ?? { y: first + i, n: 0, price: null }
);
});
const peak = $derived(car.nlYears.reduce((a, b) => (b.n > a.n ? b : a), car.nlYears[0]));
const used = $derived(
car.nlYears.filter(
(y) => y.y <= data.currentYear - 8 && y.y >= data.currentYear - 20 && y.n >= 20
)
);
const facts = $derived([
['built', years(car.firstYear, car.lastYear, data.currentYear)],
['body', car.bodies.map((b) => bodyLabel[b] ?? b).join(', ')],
['engine', range(car.engineLMin, car.engineLMax, (n) => `${n.toFixed(1)} L`)],
['cylinders', range(car.cylindersMin, car.cylindersMax)],
['turbo', car.turbo == null ? null : car.turbo ? 'some versions' : 'no'],
[
'power',
range(car.powerKwMin, car.powerKwMax, (n) => `${kwToHp(n)}`) &&
`${range(car.powerKwMin, car.powerKwMax, (n) => `${kwToHp(n)}`)} hp`
],
['drive', car.drives.map((d) => driveLabel[d] ?? d).join(', ')],
['gearbox', car.transmissions.join(', ')],
['seats', car.seats],
['doors', car.doors],
['weight', car.massKg && `${fmtNumber(car.massKg)} kg`],
[
'fuel use',
range(car.l100Min, car.l100Max, (n) => n.toFixed(1)) &&
`${range(car.l100Min, car.l100Max, (n) => n.toFixed(1))} L/100 km`
],
['CO₂', range(car.co2Min, car.co2Max) && `${range(car.co2Min, car.co2Max)} g/km`],
['US rating', range(car.mpgMin, car.mpgMax) && `${range(car.mpgMin, car.mpgMax)} mpg`],
['EV range', car.rangeKmMax && `up to ${fmtNumber(car.rangeKmMax)} km`],
['US size class', car.sizeClasses.join(', ')],
['avg new price NL', car.nlPrice && fmtEuro(car.nlPrice)]
] as [string, string | number | null][]);
let showAll = $state(false);
const shownVariants = $derived(showAll ? variants : variants.slice(0, 25));
const shortDrive = (d: string | null) =>
d
?.replace(/Front-Wheel Drive/i, 'FWD')
.replace(/Rear-Wheel Drive/i, 'RWD')
.replace(/All-Wheel Drive/i, 'AWD')
.replace(/4-Wheel or All-Wheel Drive/i, '4WD/AWD')
.replace(/4-Wheel Drive/i, '4WD')
.replace(/Part-time 4-Wheel Drive/i, 'part-time 4WD') ?? '—';
</script>
<div class="mx-auto max-w-5xl px-4 py-12 sm:px-6 sm:py-16">
<nav class="flex items-center justify-between gap-2 text-sm">
<a href="/trackers/cars" class="back"><ArrowLeft size={15} /> all cars</a>
<span class="flex items-center gap-1">
{#if data.prev}<a
href="/trackers/cars/{data.prev.brandSlug}/{data.prev.slug}"
class="step"
title={data.prev.name}><ChevronLeft size={15} />{dexLabel(data.prev.dex)}</a
>{/if}
<a
href="/trackers/cars/random"
class="step"
data-sveltekit-preload-data="off"
title="Random car"><Shuffle size={14} /></a
>
{#if data.next}<a
href="/trackers/cars/{data.next.brandSlug}/{data.next.slug}"
class="step"
title={data.next.name}>{dexLabel(data.next.dex)}<ChevronRight size={15} /></a
>{/if}
</span>
</nav>
<header class="hero">
<div class="flex items-center gap-4">
<a href="/trackers/cars/{data.brand.slug}" aria-label="{data.brand.name} models"
><BrandBadge name={data.brand.name} size="lg" /></a
>
<div class="min-w-0">
<p class="dex">{dexLabel(car.dex)}</p>
<h1 class="text-3xl font-semibold tracking-tight sm:text-5xl">
<a href="/trackers/cars/{data.brand.slug}" class="brand">{data.brand.name}</a>
{car.name}
</h1>
<p class="mt-2 text-[var(--site-muted)]">
{#if data.brand.country}{flag(data.brand.country)} {data.brand.country} ·
{/if}{years(car.firstYear, car.lastYear, data.currentYear) ??
'years unknown'}{#if car.bodies.length}
· {car.bodies.map((b) => bodyLabel[b] ?? b).join(' / ')}{/if}
</p>
</div>
</div>
{#if car.fuels.length}
<div class="mt-5 flex flex-wrap gap-2">
{#each car.fuels as fuel (fuel)}<FuelChip {fuel} title={fuelHint[fuel]} />{/each}
</div>
{/if}
</header>
{#if baseStats.length}
<section class="card" aria-labelledby="stats">
<h2 id="stats">base stats</h2>
<ul class="bars">
{#each baseStats as s (s.label)}
<li>
<span class="label">{s.label}</span>
<span class="track"
><span class="fill" style:width="{s.score * 100}%" style:--hue={s.hue}></span></span
>
<span class="value">{s.value}<small>{s.hint}</small></span>
</li>
{/each}
</ul>
<p class="note">Bars compare against all cars: 600 hp, 0 g CO₂, 400k registrations, €150k.</p>
</section>
{/if}
{#if car.fuels.length}
<section class="card" aria-labelledby="fuels">
<h2 id="fuels">what the fuel types mean</h2>
<ul class="hints">
{#each car.fuels as fuel (fuel)}
<li><FuelChip {fuel} /> <span>{fuelHint[fuel]}</span></li>
{/each}
</ul>
</section>
{/if}
<section class="card" aria-labelledby="facts">
<h2 id="facts">spec sheet</h2>
<dl class="facts">
{#each facts as [label, value] (label)}
<div>
<dt>{label}</dt>
<dd class={[!value && 'blank']}>{value || '—'}</dd>
</div>
{/each}
</dl>
<p class="note">Ranges cover every version ever sold. Blank means the sources don't say.</p>
</section>
{#if nlYears.length}
<section class="card" aria-labelledby="nl">
<h2 id="nl">🇳🇱 on Dutch roads</h2>
<p class="lead">
<strong>{fmtNumber(car.nlCount)}</strong> are registered in the Netherlands today{#if car.nlRank},
#{car.nlRank} of all models{/if}. Most common build year: <strong>{peak.y}</strong>
({fmtNumber(peak.n)} cars).
</p>
<div class="chart" role="img" aria-label="Registered cars per year of first registration">
{#each nlYears as y (y.y)}
<span
class="col"
title="{y.y}: {fmtNumber(y.n)} cars{y.price ? `, new ≈ ${fmtEuro(y.price)}` : ''}"
><span class="bar" style:height="{Math.max(y.n ? 2 : 0, (y.n / nlMax) * 100)}%"
></span></span
>
{/each}
</div>
<div class="axis"><span>{nlYears[0].y}</span><span>{nlYears.at(-1)?.y}</span></div>
<p class="note">Cars still registered, by year of first registration (RDW).</p>
{#if used.length}
<h3>secondhand shopping hint</h3>
<p class="text-sm text-[var(--site-muted)]">
Cars 8–20 years old are where budget buys usually live. The register has
<strong class="text-[var(--site-text)]"
>{fmtNumber(used.reduce((a, y) => a + y.n, 0))}</strong
>
{car.name}s from {used[0].y}–{used.at(-1)?.y}, so there should be plenty to choose from on
Marktplaats or AutoScout24. Their price when new:
</p>
<ul class="prices">
{#each used.filter((y) => y.price).slice(-6) as y (y.y)}
<li><span>{y.y}</span><strong>{fmtEuro(y.price!)}</strong></li>
{/each}
</ul>
{/if}
{#if car.nlNames.length > 1}
<details class="mt-4">
<summary>names it's registered under ({car.nlNames.length})</summary>
<ul class="names">
{#each car.nlNames as n (n.name)}<li>{n.name} <span>{fmtNumber(n.n)}</span></li>{/each}
</ul>
</details>
{/if}
</section>
{/if}
{#if variants.length}
<section class="card" aria-labelledby="us">
<h2 id="us">🇺🇸 US versions ({fmtNumber(variants.length)})</h2>
<p class="note mb-3">
Every version the EPA tested{#if car.usYears.length}, model years {car
.usYears[0]}–{car.usYears.at(-1)}{/if}. mpg is combined; L/100 km converted.
</p>
<div class="table-wrap">
<table>
<thead>
<tr
><th>year</th><th>version</th><th>engine</th><th>gearbox</th><th>drive</th><th
>fuel</th
><th class="num">mpg</th><th class="num">L/100</th></tr
>
</thead>
<tbody>
{#each shownVariants as v, i (i)}
<tr>
<td>{v.year}</td>
<td>{v.name}</td>
<td
>{#if v.evMotor && !v.engineL}{v.evMotor}{:else}{v.engineL
? `${v.engineL.toFixed(1)} L`
: ''}{v.cylinders ? ` ${v.cylinders} cyl` : ''}{v.turbo
? ' turbo'
: ''}{v.supercharged ? ' SC' : ''}{/if}{#if v.rangeMi}
· {Math.round(v.rangeMi * 1.609)} km range{/if}</td
>
<td>{v.transmission ?? '—'}</td>
<td>{shortDrive(v.drive)}</td>
<td>{v.fuel ?? '—'}</td>
<td class="num">{v.mpgComb ?? '—'}</td>
<td class="num"
>{v.mpgComb && v.fuel !== 'Electricity'
? mpgToL100(v.mpgComb).toFixed(1)
: '—'}</td
>
</tr>
{/each}
</tbody>
</table>
</div>
{#if variants.length > 25}
<button type="button" class="more" onclick={() => (showAll = !showAll)}
>{showAll ? 'show fewer' : `show all ${fmtNumber(variants.length)}`}</button
>
{/if}
</section>
{/if}
<p class="mt-8 text-xs text-[var(--site-muted)]">
Sources: {#each car.sources as s, i (s)}{i ? ', ' : ''}{#if s === 'RDW open data'}<a
href="https://opendata.rdw.nl/Voertuigen/Open-Data-RDW-Gekentekende_voertuigen/m9d7-ebf2"
class="underline">RDW open data</a
>{:else if s === 'fueleconomy.gov'}<a
href="https://www.fueleconomy.gov/feg/download.shtml"
class="underline">fueleconomy.gov</a
>{:else}{s}{/if}{/each}.{#if car.updatedAt}
Updated {car.updatedAt.slice(0, 10)}.{/if}
</p>
</div>
<style>
.back,
.step {
display: inline-flex;
align-items: center;
gap: 0.25rem;
color: var(--site-muted);
}
.step {
border: 1px solid var(--site-border);
border-radius: 999px;
padding: 0.2rem 0.6rem;
font-family: ui-monospace, monospace;
font-size: 0.75rem;
}
.back:hover,
.step:hover {
color: var(--site-text);
}
.hero {
margin: 2rem 0 1.5rem;
}
.dex {
font-family: ui-monospace, monospace;
font-size: 0.85rem;
color: var(--site-muted);
}
.brand {
color: var(--site-muted);
font-weight: 500;
}
.brand:hover {
color: var(--site-text);
}
.card {
border: 1px solid var(--site-border);
border-radius: 0.9rem;
background: var(--site-surface);
padding: 1.1rem 1.2rem;
margin-bottom: 1rem;
}
h2 {
font-size: 1rem;
font-weight: 600;
margin-bottom: 0.8rem;
}
h3 {
font-size: 0.9rem;
font-weight: 600;
margin: 1.25rem 0 0.4rem;
}
.note {
margin-top: 0.6rem;
font-size: 0.72rem;
color: var(--site-muted);
}
.bars {
display: grid;
gap: 0.55rem;
}
.bars li {
display: grid;
grid-template-columns: 5.5rem 1fr minmax(7rem, auto);
align-items: center;
gap: 0.75rem;
}
.label {
font-size: 0.8rem;
color: var(--site-muted);
}
.track {
height: 0.6rem;
border-radius: 999px;
background: var(--site-hover);
overflow: hidden;
}
.fill {
display: block;
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, hsl(var(--hue) 70% 45%), hsl(var(--hue) 85% 62%));
animation: grow 0.6s ease-out;
transform-origin: left;
}
@keyframes grow {
from {
transform: scaleX(0);
}
}
@media (prefers-reduced-motion: reduce) {
.fill {
animation: none;
}
}
.value {
font-size: 0.85rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
display: flex;
flex-direction: column;
line-height: 1.2;
}
.value small {
font-size: 0.68rem;
font-weight: 400;
color: var(--site-muted);
}
.hints {
display: grid;
gap: 0.5rem;
font-size: 0.85rem;
}
.hints li {
display: flex;
gap: 0.6rem;
align-items: baseline;
}
.hints span:last-child {
color: var(--site-muted);
}
.facts {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 12rem), 1fr));
gap: 0.75rem 1.25rem;
}
.facts dt {
font-size: 0.72rem;
color: var(--site-muted);
}
.facts dd {
font-size: 0.9rem;
font-weight: 500;
}
.facts dd.blank {
color: var(--site-muted);
font-weight: 400;
}
.lead {
font-size: 0.9rem;
color: var(--site-muted);
margin-bottom: 1rem;
}
.lead strong {
color: var(--site-text);
}
.chart {
display: flex;
align-items: flex-end;
gap: 2px;
height: 8rem;
}
.col {
flex: 1;
height: 100%;
display: flex;
align-items: flex-end;
}
.col:hover .bar {
background: hsl(210 90% 70%);
}
.bar {
width: 100%;
border-radius: 2px 2px 0 0;
background: hsl(210 70% 55%);
}
.axis {
display: flex;
justify-content: space-between;
font-size: 0.7rem;
color: var(--site-muted);
margin-top: 0.25rem;
}
.prices {
margin-top: 0.6rem;
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.prices li {
display: flex;
flex-direction: column;
border: 1px solid var(--site-border);
border-radius: 0.5rem;
padding: 0.3rem 0.6rem;
font-size: 0.75rem;
}
.prices span {
color: var(--site-muted);
}
summary {
cursor: pointer;
font-size: 0.85rem;
color: var(--site-muted);
}
.names {
margin-top: 0.5rem;
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
font-size: 0.78rem;
}
.names li {
border: 1px solid var(--site-border);
border-radius: 999px;
padding: 0.1rem 0.6rem;
}
.names span {
color: var(--site-muted);
font-variant-numeric: tabular-nums;
}
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
font-size: 0.78rem;
border-collapse: collapse;
}
th {
text-align: left;
font-weight: 500;
color: var(--site-muted);
padding: 0.35rem 0.5rem;
border-bottom: 1px solid var(--site-border);
white-space: nowrap;
}
td {
padding: 0.35rem 0.5rem;
border-bottom: 1px solid var(--site-border);
white-space: nowrap;
}
.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.more {
margin-top: 0.75rem;
font-size: 0.8rem;
border: 1px solid var(--site-border);
border-radius: 999px;
padding: 0.25rem 0.8rem;
}
.more:hover {
background: var(--site-hover);
}
</style>

View File

@@ -0,0 +1,9 @@
import { redirect } from '@sveltejs/kit';
import { randomCar } from '#lib/server/cars.js';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ setHeaders }) => {
const car = await randomCar();
setHeaders({ 'cache-control': 'no-store' });
redirect(303, car ? `/trackers/cars/${car.brandSlug}/${car.slug}` : '/trackers/cars');
};