All checks were successful
Deploy production / deploy (push) Successful in 2m24s
251 lines
8.2 KiB
TypeScript
251 lines
8.2 KiB
TypeScript
// 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 = <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) : '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<number>`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<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,
|
|
nlCount: carBrands.nlCount,
|
|
country: carBrands.country,
|
|
logo: carBrands.logo
|
|
})
|
|
.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,
|
|
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;
|
|
}
|