// 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, carPhotos, 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 = (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) : 'popular', 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, brandLogo: carBrands.logo, firstYear: carModels.firstYear, lastYear: carModels.lastYear, fuels: carModels.fuels, bodies: carModels.bodies, nlCount: carModels.nlCount, nlPriceLatest: carModels.nlPriceLatest, powerKwMax: carModels.powerKwMax, photo: carModels.photo }; 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`count(*)::int` }) .from(carModels) .innerJoin(carBrands, eq(carBrands.id, carModels.brandId)) .where(condition) ]); return { cars, total }; } export type BrandTile = { slug: string; name: string; models: number; nlCount: number; country: string | null; logo: string | null; }; type Overview = { /** Alphabetical (for the filter); `byNl` has the same brands, most common in NL first. */ brands: BrandTile[]; byNl: BrandTile[]; 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 { 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, nlCount: carBrands.nlCount, country: carBrands.country, logo: carBrands.logo }) .from(carBrands) .orderBy(asc(carBrands.name)), db .select({ models: sql`count(*)::int`, variants: sql`coalesce(sum(${carModels.variantCount}), 0)::int`, nlCars: sql`coalesce(sum(${carModels.nlCount}), 0)::bigint` }) .from(carModels) ]); const value = { brands, byNl: [...brands].sort( (a, b) => b.nlCount - a.nlCount || b.models - a.models || a.name.localeCompare(b.name) ), stats: { brands: brands.length, models: counts.models, variants: counts.variants, nlCars: Number(counts.nlCars) } }; if (value.stats.models) 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)); const [hero] = brand.photo ? await db .select({ file: carPhotos.file, author: carPhotos.author, license: carPhotos.license, licenseUrl: carPhotos.licenseUrl, model: carModels.name }) .from(carPhotos) .innerJoin(carModels, eq(carModels.id, carPhotos.modelId)) .where(and(eq(carModels.brandId, brand.id), eq(carPhotos.thumb, brand.photo))) .limit(1) : []; return { brand, models, hero: hero ?? null }; } 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, photos] = 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}))` ), db .select({ file: carPhotos.file, kind: carPhotos.kind, caption: carPhotos.caption, thumb: carPhotos.thumb, width: carPhotos.width, height: carPhotos.height, author: carPhotos.author, license: carPhotos.license, licenseUrl: carPhotos.licenseUrl }) .from(carPhotos) .where(eq(carPhotos.modelId, row.model.id)) .orderBy(asc(carPhotos.position)) ]); return { ...row, variants, photos, 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; }