trains: pokedex pages (builders, operators, countries, detail, random) and registrations
All checks were successful
Deploy production / deploy (push) Successful in 1m9s

This commit is contained in:
2026-10-25 00:11:50 +00:00
parent 8c0fc74283
commit 5f940e8196
28 changed files with 2630 additions and 0 deletions

View File

@@ -43,6 +43,8 @@ describe('owner gate', () => {
expect(isOwnerOnlyPath('/trackers/cars/ford/mustang')).toBe(false);
expect(isOwnerWritePath('/trackers/planes/boeing/737')).toBe(true);
expect(isOwnerOnlyPath('/trackers/planes/boeing/737')).toBe(false);
expect(isOwnerWritePath('/trackers/trains/siemens/virm')).toBe(true);
expect(isOwnerOnlyPath('/trackers/trains/siemens/virm')).toBe(false);
expect(isOwnerWritePath('/trackers/wildlife/mammals/vulpes-vulpes')).toBe(true);
expect(isOwnerOnlyPath('/trackers/wildlife/mammals/vulpes-vulpes')).toBe(false);
expect(isOwnerWritePath('/trackers/pc')).toBe(true);

View File

@@ -50,6 +50,7 @@ export const ownerWritePrefixes = [
'/trackers/books',
'/trackers/cars',
'/trackers/planes',
'/trackers/trains',
'/trackers/wildlife',
'/trackers/music',
'/trackers/pc'

377
src/lib/server/trains.ts Normal file
View File

@@ -0,0 +1,377 @@
// Train pokedex queries (/trackers/trains). Public, read-only; data is written only by
// scripts/trains-import.ts and scripts/trains-photos.ts.
import { db } from '#lib/server/db/index.js';
import {
trainMakers,
trainOperators,
trainPhotos,
trainTypeOperators,
trainTypes
} from '#lib/server/db/schema/index.js';
import { and, arrayContains, asc, desc, eq, gte, ilike, lte, or, sql, type SQL } from 'drizzle-orm';
import {
CATEGORIES,
ERAS,
SORTS,
TRACTIONS,
countrySlug,
type TrainCard,
type TrainEra,
type TrainSort
} from '#lib/trains/shared.js';
export const PAGE_SIZE = 60;
export type TrainFilters = {
q: string;
maker: string;
category: string;
traction: string;
country: string;
era: TrainEra | '';
service: '' | 'yes' | 'no';
sort: TrainSort;
page: number;
};
const pick = <T extends string>(value: string | null, allowed: readonly T[]): T | '' =>
allowed.includes(value as T) ? (value as T) : '';
export function parseFilters(params: URLSearchParams): TrainFilters {
const sort = params.get('sort');
return {
q: (params.get('q') ?? '').trim().slice(0, 80),
maker: (params.get('maker') ?? '').replace(/[^a-z0-9-]/g, '').slice(0, 80),
category: pick(params.get('category'), CATEGORIES),
traction: pick(params.get('traction'), TRACTIONS),
country: (params.get('country') ?? '').trim().slice(0, 80),
era: pick(params.get('era'), Object.keys(ERAS) as TrainEra[]),
service: pick(params.get('service'), ['yes', 'no'] as const),
sort: sort && sort in SORTS ? (sort as TrainSort) : 'built',
page: Math.min(Math.max(1, Math.floor(Number(params.get('page')) || 1)), 1000)
};
}
const cardColumns = {
dex: trainTypes.dex,
slug: trainTypes.slug,
name: trainTypes.name,
fullName: trainTypes.fullName,
makerSlug: trainMakers.slug,
makerName: trainMakers.name,
makerLogo: trainMakers.logo,
category: trainTypes.category,
traction: trainTypes.traction,
country: trainTypes.country,
firstBuilt: trainTypes.firstBuilt,
inService: trainTypes.inService,
status: trainTypes.status,
numberBuilt: trainTypes.numberBuilt,
maxSpeedKmh: trainTypes.maxSpeedKmh,
powerKw: trainTypes.powerKw,
photo: trainTypes.photo
};
const escapeLike = (s: string) => s.replace(/[\\%_]/g, (c) => `\\${c}`);
const order = (sort: TrainSort) =>
({
built: [sql`${trainTypes.numberBuilt} desc nulls last`, asc(trainTypes.dex)],
dex: [asc(trainTypes.dex)],
name: [asc(trainTypes.fullName)],
newest: [sql`${trainTypes.firstBuilt} desc nulls last`, asc(trainTypes.dex)],
oldest: [sql`${trainTypes.firstBuilt} asc nulls last`, asc(trainTypes.dex)],
fastest: [sql`${trainTypes.maxSpeedKmh} desc nulls last`, asc(trainTypes.dex)],
power: [sql`${trainTypes.powerKw} desc nulls last`, asc(trainTypes.dex)]
})[sort];
export async function searchTrains(
f: TrainFilters
): Promise<{ trains: TrainCard[]; total: number }> {
const where: SQL[] = [];
for (const word of f.q.toLowerCase().split(/\s+/).filter(Boolean).slice(0, 6)) {
// "virm" / "1700" / "ice3" also match "ice 3" via the compact name stored in search.
const compact = word.replace(/[^a-z0-9]/g, '');
const like = ilike(trainTypes.search, `%${escapeLike(word)}%`);
where.push(
compact && compact !== word ? or(like, ilike(trainTypes.search, `%${compact}%`))! : like
);
}
if (f.maker) where.push(eq(trainMakers.slug, f.maker));
if (f.category) where.push(eq(trainTypes.category, f.category));
if (f.traction) where.push(eq(trainTypes.traction, f.traction));
if (f.country) where.push(arrayContains(trainTypes.countries, [f.country]));
if (f.era) {
const era = ERAS[f.era];
if (era.from != null) where.push(gte(trainTypes.firstBuilt, era.from));
if (era.to != null) where.push(lte(trainTypes.firstBuilt, era.to));
}
if (f.service === 'yes') where.push(eq(trainTypes.inService, true));
if (f.service === 'no') where.push(eq(trainTypes.inService, false));
const condition = where.length ? and(...where) : undefined;
const [trains, [{ total }]] = await Promise.all([
db
.select(cardColumns)
.from(trainTypes)
.innerJoin(trainMakers, eq(trainMakers.id, trainTypes.makerId))
.where(condition)
.orderBy(...order(f.sort))
.limit(PAGE_SIZE)
.offset((f.page - 1) * PAGE_SIZE),
db
.select({ total: sql<number>`count(*)::int` })
.from(trainTypes)
.innerJoin(trainMakers, eq(trainMakers.id, trainTypes.makerId))
.where(condition)
]);
return { trains, total };
}
export type MakerTile = {
slug: string;
name: string;
types: number;
built: number;
country: string | null;
logo: string | null;
};
export type OperatorTile = {
slug: string;
name: string;
types: number;
country: string | null;
logo: string | null;
};
export type CountryTile = { slug: string; name: string; types: number; photo: string | null };
type Overview = {
/** Alphabetical (for the filter); `byBuilt` has the same makers, most built first. */
makers: MakerTile[];
byBuilt: MakerTile[];
operators: OperatorTile[];
countries: CountryTile[];
stats: {
makers: number;
operators: number;
countries: number;
types: number;
built: number;
photos: number;
};
};
let overviewCache: { at: number; value: Overview } | null = null;
/** Maker/operator/country lists and headline counts; cached for 10 minutes. */
export async function trainOverview(): Promise<Overview> {
if (overviewCache && Date.now() - overviewCache.at < 600_000) return overviewCache.value;
const [makers, operators, countries, [counts]] = await Promise.all([
db
.select({
slug: trainMakers.slug,
name: trainMakers.name,
types: trainMakers.typeCount,
built: trainMakers.builtTotal,
country: trainMakers.country,
logo: trainMakers.logo
})
.from(trainMakers)
.orderBy(asc(trainMakers.name)),
db
.select({
slug: trainOperators.slug,
name: trainOperators.name,
types: trainOperators.typeCount,
country: trainOperators.country,
logo: trainOperators.logo
})
.from(trainOperators)
.where(sql`${trainOperators.typeCount} > 0`)
.orderBy(desc(trainOperators.typeCount), asc(trainOperators.name)),
db.execute(sql`
select c as name, count(*)::int as types,
(array_agg(t.photo order by t.number_built desc nulls last) filter (where t.photo is not null))[1] as photo
from trains.types t, unnest(t.countries) c group by c order by types desc, c`),
db
.select({
types: sql<number>`count(*)::int`,
built: sql<number>`coalesce(sum(${trainTypes.numberBuilt}) filter (where ${trainTypes.familyId} is null), 0)::bigint`,
photos: sql<number>`coalesce(sum(${trainTypes.photoCount}), 0)::int`
})
.from(trainTypes)
]);
const countryRows = [...countries] as { name: string; types: number; photo: string | null }[];
const value = {
makers,
byBuilt: [...makers].sort(
(a, b) => b.built - a.built || b.types - a.types || a.name.localeCompare(b.name)
),
operators,
countries: countryRows.map((c) => ({ ...c, slug: countrySlug(c.name) })),
stats: {
makers: makers.length,
operators: operators.length,
countries: countryRows.length,
types: counts.types,
built: Number(counts.built),
photos: counts.photos
}
};
if (value.stats.types) overviewCache = { at: Date.now(), value };
return value;
}
const heroFor = (where: SQL, photo: string) =>
db
.select({
file: trainPhotos.file,
author: trainPhotos.author,
license: trainPhotos.license,
licenseUrl: trainPhotos.licenseUrl,
type: trainTypes.name
})
.from(trainPhotos)
.innerJoin(trainTypes, eq(trainTypes.id, trainPhotos.typeId))
.where(and(where, eq(trainPhotos.thumb, photo)))
.limit(1);
export async function getMaker(slug: string) {
const [maker] = await db.select().from(trainMakers).where(eq(trainMakers.slug, slug)).limit(1);
if (!maker) return null;
const types = await db
.select(cardColumns)
.from(trainTypes)
.innerJoin(trainMakers, eq(trainMakers.id, trainTypes.makerId))
.where(eq(trainTypes.makerId, maker.id))
.orderBy(sql`${trainTypes.numberBuilt} desc nulls last`, asc(trainTypes.fullName));
const [hero] = maker.photo ? await heroFor(eq(trainTypes.makerId, maker.id), maker.photo) : [];
return { maker, types, hero: hero ?? null };
}
export async function getOperator(slug: string) {
const [operator] = await db
.select()
.from(trainOperators)
.where(eq(trainOperators.slug, slug))
.limit(1);
if (!operator) return null;
const types = await db
.select(cardColumns)
.from(trainTypeOperators)
.innerJoin(trainTypes, eq(trainTypes.id, trainTypeOperators.typeId))
.innerJoin(trainMakers, eq(trainMakers.id, trainTypes.makerId))
.where(eq(trainTypeOperators.operatorId, operator.id))
.orderBy(sql`${trainTypes.firstBuilt} asc nulls last`, asc(trainTypes.fullName));
const [hero] = operator.photo
? await heroFor(
sql`${trainTypes.id} in (select type_id from trains.type_operators where operator_id = ${operator.id})`,
operator.photo
)
: [];
return { operator, types, hero: hero ?? null };
}
export async function getCountry(slug: string) {
const { countries } = await trainOverview();
const country = countries.find((c) => c.slug === slug);
if (!country) return null;
const types = await db
.select(cardColumns)
.from(trainTypes)
.innerJoin(trainMakers, eq(trainMakers.id, trainTypes.makerId))
.where(arrayContains(trainTypes.countries, [country.name]))
.orderBy(sql`${trainTypes.firstBuilt} asc nulls last`, asc(trainTypes.fullName))
.limit(1500);
return { country, types };
}
export async function getType(makerSlug: string, typeSlug: string) {
const [row] = await db
.select({ type: trainTypes, maker: trainMakers })
.from(trainTypes)
.innerJoin(trainMakers, eq(trainMakers.id, trainTypes.makerId))
.where(and(eq(trainMakers.slug, makerSlug), eq(trainTypes.slug, typeSlug)))
.limit(1);
if (!row) return null;
const t = row.type;
const [neighbours, photos, family, operators] = await Promise.all([
db
.select({
dex: trainTypes.dex,
slug: trainTypes.slug,
name: trainTypes.fullName,
makerSlug: trainMakers.slug
})
.from(trainTypes)
.innerJoin(trainMakers, eq(trainMakers.id, trainTypes.makerId))
.where(
sql`${trainTypes.dex} in (
(select max(dex) from trains.types where dex < ${t.dex}),
(select min(dex) from trains.types where dex > ${t.dex}))`
),
db
.select({
file: trainPhotos.file,
kind: trainPhotos.kind,
caption: trainPhotos.caption,
thumb: trainPhotos.thumb,
width: trainPhotos.width,
height: trainPhotos.height,
author: trainPhotos.author,
license: trainPhotos.license,
licenseUrl: trainPhotos.licenseUrl
})
.from(trainPhotos)
.where(eq(trainPhotos.typeId, t.id))
.orderBy(asc(trainPhotos.position)),
// Parent (if any) and direct variants.
db
.select({ ...cardColumns, id: trainTypes.id })
.from(trainTypes)
.innerJoin(trainMakers, eq(trainMakers.id, trainTypes.makerId))
.where(
t.familyId != null
? or(eq(trainTypes.familyId, t.id), eq(trainTypes.id, t.familyId))
: eq(trainTypes.familyId, t.id)
)
.orderBy(sql`${trainTypes.firstBuilt} asc nulls last`, asc(trainTypes.fullName)),
db
.select({
slug: trainOperators.slug,
name: trainOperators.name,
logo: trainOperators.logo,
country: trainOperators.country
})
.from(trainTypeOperators)
.innerJoin(trainOperators, eq(trainOperators.id, trainTypeOperators.operatorId))
.where(eq(trainTypeOperators.typeId, t.id))
.orderBy(asc(trainTypeOperators.position))
]);
const parent = family.find((f) => f.id === t.familyId) ?? null;
return {
...row,
photos,
parent,
operators,
variants: family.filter((f) => f.id !== t.familyId),
prev: neighbours.find((n) => n.dex < t.dex) ?? null,
next: neighbours.find((n) => n.dex > t.dex) ?? null
};
}
/** A random type with a photo, favouring ones built in numbers. */
export async function randomTrain() {
const [row] = await db
.select({ slug: trainTypes.slug, makerSlug: trainMakers.slug })
.from(trainTypes)
.innerJoin(trainMakers, eq(trainMakers.id, trainTypes.makerId))
.where(sql`${trainTypes.photo} is not null and coalesce(${trainTypes.numberBuilt}, 0) >= 5`)
.orderBy(sql`random()`)
.limit(1);
if (row) return row;
// Before the photo pass has run: any type at all.
const [any] = await db
.select({ slug: trainTypes.slug, makerSlug: trainMakers.slug })
.from(trainTypes)
.innerJoin(trainMakers, eq(trainMakers.id, trainTypes.makerId))
.orderBy(sql`random()`)
.limit(1);
return any ?? null;
}