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
Some checks failed
Deploy production / deploy (push) Has been cancelled
This commit is contained in:
@@ -24,7 +24,8 @@ export default defineConfig({
|
||||
'books',
|
||||
'tasks',
|
||||
'groceries',
|
||||
'home'
|
||||
'home',
|
||||
'cars'
|
||||
],
|
||||
migrations: { schema: 'drizzle', table: '__drizzle_migrations' },
|
||||
verbose: true,
|
||||
|
||||
134
drizzle/20261024153902_cars/migration.sql
Normal file
134
drizzle/20261024153902_cars/migration.sql
Normal 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 $$;
|
||||
18350
drizzle/20261024153902_cars/snapshot.json
Normal file
18350
drizzle/20261024153902_cars/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
1055
scripts/cars-import.ts
Normal file
File diff suppressed because it is too large
Load Diff
43
src/lib/cars/BrandBadge.svelte
Normal file
43
src/lib/cars/BrandBadge.svelte
Normal 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
125
src/lib/cars/CarCard.svelte
Normal 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>
|
||||
63
src/lib/cars/FuelChip.svelte
Normal file
63
src/lib/cars/FuelChip.svelte
Normal 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
167
src/lib/cars/shared.ts
Normal 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;
|
||||
};
|
||||
@@ -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]+$/);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
198
src/lib/server/cars.ts
Normal 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;
|
||||
}
|
||||
137
src/lib/server/db/schema/cars.ts
Normal file
137
src/lib/server/db/schema/cars.ts
Normal 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)]
|
||||
);
|
||||
@@ -17,3 +17,4 @@ export * from './books';
|
||||
export * from './tasks';
|
||||
export * from './groceries';
|
||||
export * from './home';
|
||||
export * from './cars';
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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(/\/+$/, '');
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -14,6 +14,7 @@ const staticPaths = [
|
||||
'/blog',
|
||||
'/lists',
|
||||
'/trackers/books',
|
||||
'/trackers/cars',
|
||||
'/projects/game-tracker',
|
||||
'/projects/game-tracker/pokemon',
|
||||
'/projects/game-tracker/osu',
|
||||
|
||||
20
src/routes/trackers/cars/+page.server.ts
Normal file
20
src/routes/trackers/cars/+page.server.ts
Normal 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.`
|
||||
}
|
||||
};
|
||||
};
|
||||
325
src/routes/trackers/cars/+page.svelte
Normal file
325
src/routes/trackers/cars/+page.svelte
Normal 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>
|
||||
27
src/routes/trackers/cars/[brand]/+page.server.ts
Normal file
27
src/routes/trackers/cars/[brand]/+page.server.ts
Normal 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.`
|
||||
}
|
||||
};
|
||||
};
|
||||
53
src/routes/trackers/cars/[brand]/+page.svelte
Normal file
53
src/routes/trackers/cars/[brand]/+page.svelte
Normal 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>
|
||||
46
src/routes/trackers/cars/[brand]/[model]/+page.server.ts
Normal file
46
src/routes/trackers/cars/[brand]/[model]/+page.server.ts
Normal 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.`
|
||||
}
|
||||
};
|
||||
};
|
||||
576
src/routes/trackers/cars/[brand]/[model]/+page.svelte
Normal file
576
src/routes/trackers/cars/[brand]/[model]/+page.svelte
Normal 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>
|
||||
9
src/routes/trackers/cars/random/+server.ts
Normal file
9
src/routes/trackers/cars/random/+server.ts
Normal 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');
|
||||
};
|
||||
Reference in New Issue
Block a user