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

@@ -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',