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:
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(/\/+$/, '');
|
||||
|
||||
Reference in New Issue
Block a user