spotters: most-active-in sort with country/region selector, collection tracking (seen marks, progress, filters), refresh scripts score activity and report new NL entries
Some checks failed
Deploy production / deploy (push) Has been cancelled

This commit is contained in:
2026-10-25 13:24:37 +00:00
parent 3f294df337
commit 253cd69424
58 changed files with 2151 additions and 72 deletions

View File

@@ -1,18 +1,41 @@
import { carOverview, PAGE_SIZE, parseFilters, searchCars } from '#lib/server/cars.js';
import {
areasWithData,
groupActivity,
progress,
rankByActivity,
seenByGroup
} from '#lib/server/spotting.js';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ url }) => {
const filters = parseFilters(url.searchParams);
const [{ cars, total }, overview] = await Promise.all([searchCars(filters), carOverview()]);
const [{ cars, total }, overview, collected, seenBrands, areas, act] = await Promise.all([
searchCars(filters),
carOverview(),
progress('cars'),
seenByGroup('cars'),
areasWithData('cars'),
groupActivity('cars', filters.area)
]);
return {
filters,
cars,
total,
pageSize: PAGE_SIZE,
collected,
areas,
brands: overview.brands.map(({ slug, name, models }) => ({ slug, name, models })),
topBrands: overview.byNl
topBrands: rankByActivity(overview.byNl, act)
.slice(0, 16)
.map(({ slug, name, models, nlCount, logo }) => ({ slug, name, models, nlCount, logo })),
.map(({ id, slug, name, models, nlCount, logo }) => ({
slug,
name,
models,
nlCount,
logo,
seen: seenBrands.get(id) ?? 0
})),
stats: overview.stats,
currentYear: new Date().getFullYear(),
seo: {

View File

@@ -14,6 +14,10 @@
LOGO_NOTE,
SORTS
} from '#lib/cars/shared.js';
import AreaSelect from '#lib/spotting/AreaSelect.svelte';
import { GOT } from '#lib/spotting/areas.js';
import SpotItem from '#lib/spotting/SpotItem.svelte';
import SpotProgress from '#lib/spotting/SpotProgress.svelte';
import { Input } from '#lib/components/ui/input/index.js';
import { NativeSelect } from '#lib/components/ui/native-select/index.js';
import Search from 'lucide-svelte/icons/search';
@@ -26,7 +30,7 @@
const f = $derived(data.filters);
const pages = $derived(Math.max(1, Math.ceil(data.total / data.pageSize)));
const filtering = $derived(
!!(f.q || f.brand || f.fuel || f.body || f.drive || f.market || f.from || f.to)
!!(f.q || f.brand || f.fuel || f.body || f.drive || f.market || f.from || f.to || f.got)
);
let form: HTMLFormElement | undefined = $state();
@@ -51,13 +55,14 @@
return `/trackers/cars${qs ? `?${qs}` : ''}`;
}
const quick = [
const quick: { label: string; params: Record<string, string> }[] = [
{ label: '📖 dex order', params: { sort: 'dex' } },
{ label: '⚡ electric', params: { fuel: 'electric', sort: 'popular' } },
{ label: '🍃 hybrids', params: { fuel: 'hybrid', sort: 'popular' } },
{ label: '🏎️ coupés', params: { body: 'coupe', sort: 'popular' } },
{ label: '💪 most powerful', params: { sort: 'power' } },
{ label: '🐎 Mustang', params: { q: 'mustang' } }
{ label: '🐎 Mustang', params: { q: 'mustang' } },
{ label: '⬜ still to go', params: { got: 'no' } }
];
</script>
@@ -94,6 +99,15 @@
</div>
</dl>
<div class="mb-8">
<SpotProgress
seen={data.collected.seen}
total={data.collected.total}
label="models collected"
href={withParams({ got: 'no' })}
/>
</div>
{#if !filtering && f.page === 1}
<section class="mb-8" aria-labelledby="brands-heading">
<div class="mb-3 flex items-baseline justify-between gap-3">
@@ -184,6 +198,14 @@
{#each Object.entries(SORTS) as [key, label] (key)}<option value={key}>{label}</option
>{/each}
</NativeSelect>
{#if f.sort === 'active'}<AreaSelect
value={f.area}
available={data.areas}
onchange={submit}
/>{/if}
<NativeSelect name="got" aria-label="Collection" value={f.got} onchange={submit}>
{#each Object.entries(GOT) as [key, label] (key)}<option value={key}>{label}</option>{/each}
</NativeSelect>
<noscript><button type="submit" class="pill">search</button></noscript>
</div>
</form>
@@ -206,7 +228,11 @@
{#if data.cars.length}
<ul class="grid">
{#each data.cars as car, i (car.dex)}
<li><CarCard {car} current={data.currentYear} eager={i < 4} /></li>
<li>
<SpotItem tracker="cars" id={car.id} seen={car.seen} label={car.name}>
<CarCard {car} current={data.currentYear} eager={i < 4} />
</SpotItem>
</li>
{/each}
</ul>
{:else}

View File

@@ -10,11 +10,18 @@
thumbSrcset,
years
} from '#lib/cars/shared.js';
import { page } from '$app/state';
import { parseGot } from '#lib/spotting/areas.js';
import { countSeen, filterGot } from '#lib/spotting/seen.svelte.js';
import SpotItem from '#lib/spotting/SpotItem.svelte';
import SpotProgress from '#lib/spotting/SpotProgress.svelte';
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
const b = $derived(data.brand);
const got = $derived(parseGot(page.url.searchParams.get('got')));
const shown = $derived(filterGot('cars', data.models, got));
</script>
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
@@ -57,9 +64,21 @@
</div>
</header>
<div class="mb-6">
<SpotProgress
seen={countSeen('cars', data.models)}
total={data.models.length}
label="{b.name} models collected"
{got}
/>
</div>
<ul class="grid">
{#each data.models as car, i (car.dex)}
<li><CarCard {car} current={data.currentYear} eager={i < 4} /></li>
{#each shown as car, i (car.dex)}
<li>
<SpotItem tracker="cars" id={car.id} seen={car.seen} label={car.name}>
<CarCard {car} current={data.currentYear} eager={i < 4} />
</SpotItem>
</li>
{/each}
</ul>
<p class="mt-10 text-xs text-[var(--site-muted)]">

View File

@@ -1,6 +1,7 @@
import { error } from '@sveltejs/kit';
import { getModel } from '#lib/server/cars.js';
import type { carModels, carVariants } from '#lib/server/db/schema/index.js';
import { getSeen } from '#lib/server/spotting.js';
import type { PageServerLoad } from './$types';
type Model = typeof carModels.$inferSelect;
@@ -32,7 +33,9 @@ export const load: PageServerLoad = async ({ params }) => {
const found = await getModel(params.brand, params.model);
if (!found) error(404, 'No such car in the pokedex');
const { model, brand, variants, photos, prev, next } = found;
const seenEntry = await getSeen('cars', model.id);
return {
spot: { id: model.id, entry: seenEntry },
car: publicCar(model),
brand: { slug: brand.slug, name: brand.name, country: brand.country, logo: brand.logo },
variants: variants.map(publicVariant),

View File

@@ -19,6 +19,7 @@
import ChevronLeft from 'lucide-svelte/icons/chevron-left';
import ChevronRight from 'lucide-svelte/icons/chevron-right';
import Shuffle from 'lucide-svelte/icons/shuffle';
import SeenPanel from '#lib/spotting/SeenPanel.svelte';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
@@ -174,6 +175,7 @@
</div>
{/if}
</header>
<SeenPanel tracker="cars" id={data.spot.id} entry={data.spot.entry} label="this one" />
{#if data.photos.length}
<Gallery photos={data.photos} name="{data.brand.name} {car.name}" wikipedia={car.wikipedia} />

View File

@@ -1,10 +1,12 @@
import { carOverview } from '#lib/server/cars.js';
import { seenByGroup } from '#lib/server/spotting.js';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
const { byNl, stats } = await carOverview();
const [{ byNl, stats }, seen] = await Promise.all([carOverview(), seenByGroup('cars')]);
return {
brands: byNl.map(({ slug, name, models, nlCount, logo }) => ({
brands: byNl.map(({ id, slug, name, models, nlCount, logo }) => ({
seen: seen.get(id) ?? 0,
slug,
name,
models,

View File

@@ -0,0 +1,14 @@
import { error, json } from '@sveltejs/kit';
import { isOwner } from '#lib/server/owner.js';
import { parseSeenBody, setSeen } from '#lib/server/spotting.js';
import type { RequestHandler } from './$types';
/** Owner-only: mark / unmark an entry as seen (collected), optionally with a date and note. */
export const POST: RequestHandler = async (event) => {
if (!isOwner(event)) error(404, 'Not Found');
const body = parseSeenBody(await event.request.json().catch(() => null));
if (!body) error(400, 'Bad request');
const ok = await setSeen('cars', body.id, body.seen, body.seenOn, body.note);
if (!ok) error(404, 'No such entry');
return json({ ok: true, ...body });
};

View File

@@ -1,18 +1,41 @@
import { PAGE_SIZE, parseFilters, planeOverview, searchPlanes } from '#lib/server/planes.js';
import {
areasWithData,
groupActivity,
progress,
rankByActivity,
seenByGroup
} from '#lib/server/spotting.js';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ url }) => {
const filters = parseFilters(url.searchParams);
const [{ planes, total }, overview] = await Promise.all([searchPlanes(filters), planeOverview()]);
const [{ planes, total }, overview, collected, seenMakers, areas, act] = await Promise.all([
searchPlanes(filters),
planeOverview(),
progress('planes'),
seenByGroup('planes'),
areasWithData('planes'),
groupActivity('planes', filters.area)
]);
return {
filters,
planes,
total,
pageSize: PAGE_SIZE,
collected,
areas,
makers: overview.makers.map(({ slug, name, types }) => ({ slug, name, types })),
topMakers: overview.byBuilt
topMakers: rankByActivity(overview.byBuilt, act)
.slice(0, 16)
.map(({ slug, name, types, built, logo }) => ({ slug, name, types, built, logo })),
.map(({ id, slug, name, types, built, logo }) => ({
slug,
name,
types,
built,
logo,
seen: seenMakers.get(id) ?? 0
})),
stats: overview.stats,
seo: {
title: 'Plane pokedex',

View File

@@ -14,6 +14,10 @@
roleLabel,
SORTS
} from '#lib/planes/shared.js';
import AreaSelect from '#lib/spotting/AreaSelect.svelte';
import { GOT } from '#lib/spotting/areas.js';
import SpotItem from '#lib/spotting/SpotItem.svelte';
import SpotProgress from '#lib/spotting/SpotProgress.svelte';
import { Input } from '#lib/components/ui/input/index.js';
import { NativeSelect } from '#lib/components/ui/native-select/index.js';
import Search from 'lucide-svelte/icons/search';
@@ -25,7 +29,9 @@
const f = $derived(data.filters);
const pages = $derived(Math.max(1, Math.ceil(data.total / data.pageSize)));
const filtering = $derived(!!(f.q || f.maker || f.role || f.engine || f.era || f.service));
const filtering = $derived(
!!(f.q || f.maker || f.role || f.engine || f.era || f.service || f.got)
);
let form: HTMLFormElement | undefined = $state();
let timer: ReturnType<typeof setTimeout> | undefined;
@@ -49,7 +55,7 @@
return `/trackers/planes${qs ? `?${qs}` : ''}`;
}
const quick = [
const quick: { label: string; params: Record<string, string> }[] = [
{ label: '📖 dex order', params: { sort: 'dex' } },
{ label: '🛫 airliners', params: { role: 'airliner', sort: 'built' } },
{ label: '🚁 helicopters', params: { role: 'helicopter', sort: 'built' } },
@@ -57,7 +63,8 @@
{ label: '🎖️ WW2 warbirds', params: { role: 'military', era: 'ww2', sort: 'built' } },
{ label: '🚀 fastest', params: { sort: 'fastest' } },
{ label: '🐘 heaviest', params: { sort: 'biggest' } },
{ label: '✈️ 737', params: { q: '737' } }
{ label: '✈️ 737', params: { q: '737' } },
{ label: '⬜ still to go', params: { got: 'no' } }
];
</script>
@@ -94,6 +101,15 @@
</div>
</dl>
<div class="mb-8">
<SpotProgress
seen={data.collected.seen}
total={data.collected.total}
label="types collected"
href={withParams({ got: 'no' })}
/>
</div>
{#if !filtering && f.page === 1}
<section class="mb-8" aria-labelledby="makers-heading">
<div class="mb-3 flex items-baseline justify-between gap-3">
@@ -159,6 +175,14 @@
{#each Object.entries(SORTS) as [key, label] (key)}<option value={key}>{label}</option
>{/each}
</NativeSelect>
{#if f.sort === 'active'}<AreaSelect
value={f.area}
available={data.areas}
onchange={submit}
/>{/if}
<NativeSelect name="got" aria-label="Collection" value={f.got} onchange={submit}>
{#each Object.entries(GOT) as [key, label] (key)}<option value={key}>{label}</option>{/each}
</NativeSelect>
<noscript><button type="submit" class="pill">search</button></noscript>
</div>
</form>
@@ -181,7 +205,11 @@
{#if data.planes.length}
<ul class="grid">
{#each data.planes as plane, i (plane.dex)}
<li><PlaneCard {plane} eager={i < 4} /></li>
<li>
<SpotItem tracker="planes" id={plane.id} seen={plane.seen} label={plane.fullName}>
<PlaneCard {plane} eager={i < 4} />
</SpotItem>
</li>
{/each}
</ul>
{:else}

View File

@@ -11,10 +11,17 @@
thumbSrcset
} from '#lib/planes/shared.js';
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
import { page } from '$app/state';
import { parseGot } from '#lib/spotting/areas.js';
import { countSeen, filterGot } from '#lib/spotting/seen.svelte.js';
import SpotItem from '#lib/spotting/SpotItem.svelte';
import SpotProgress from '#lib/spotting/SpotProgress.svelte';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
const b = $derived(data.maker);
const got = $derived(parseGot(page.url.searchParams.get('got')));
const shown = $derived(filterGot('planes', data.types, got));
</script>
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
@@ -61,9 +68,21 @@
</div>
</header>
<div class="mb-6">
<SpotProgress
seen={countSeen('planes', data.types)}
total={data.types.length}
label="{b.name} types collected"
{got}
/>
</div>
<ul class="grid">
{#each data.types as plane, i (plane.dex)}
<li><PlaneCard {plane} eager={i < 4} /></li>
{#each shown as plane, i (plane.dex)}
<li>
<SpotItem tracker="planes" id={plane.id} seen={plane.seen} label={plane.fullName}>
<PlaneCard {plane} eager={i < 4} />
</SpotItem>
</li>
{/each}
</ul>
<p class="mt-10 text-xs text-[var(--site-muted)]">

View File

@@ -1,6 +1,7 @@
import { error } from '@sveltejs/kit';
import { getType } from '#lib/server/planes.js';
import type { planeTypes } from '#lib/server/db/schema/index.js';
import { getSeen } from '#lib/server/spotting.js';
import type { PageServerLoad } from './$types';
type Type = typeof planeTypes.$inferSelect;
@@ -28,12 +29,14 @@ export const load: PageServerLoad = async ({ params }) => {
const found = await getType(params.maker, params.type);
if (!found) error(404, 'No such aircraft in the pokedex');
const { type, maker, photos, parent, variants, prev, next } = found;
const seenEntry = await getSeen('planes', type.id);
const strip = <T extends { id: number }>(row: T): Omit<T, 'id'> => {
const rest: Partial<T> = { ...row };
delete rest.id;
return rest as Omit<T, 'id'>;
};
return {
spot: { id: type.id, entry: seenEntry },
plane: publicType(type),
maker: { slug: maker.slug, name: maker.name, country: maker.country, logo: maker.logo },
photos,

View File

@@ -17,6 +17,7 @@
import ChevronLeft from 'lucide-svelte/icons/chevron-left';
import ChevronRight from 'lucide-svelte/icons/chevron-right';
import Shuffle from 'lucide-svelte/icons/shuffle';
import SeenPanel from '#lib/spotting/SeenPanel.svelte';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
@@ -215,6 +216,7 @@
>{:else if p.inService === false}<span class="chip">retired</span>{/if}
</div>
</header>
<SeenPanel tracker="planes" id={data.spot.id} entry={data.spot.entry} label="this one" />
{#if data.photos.length}
<Gallery photos={data.photos} name={p.fullName} wikipedia={p.wikipedia} />

View File

@@ -1,10 +1,12 @@
import { planeOverview } from '#lib/server/planes.js';
import { seenByGroup } from '#lib/server/spotting.js';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
const { byBuilt, stats } = await planeOverview();
const [{ byBuilt, stats }, seen] = await Promise.all([planeOverview(), seenByGroup('planes')]);
return {
makers: byBuilt.map(({ slug, name, types, built, logo }) => ({
makers: byBuilt.map(({ id, slug, name, types, built, logo }) => ({
seen: seen.get(id) ?? 0,
slug,
name,
types,

View File

@@ -0,0 +1,14 @@
import { error, json } from '@sveltejs/kit';
import { isOwner } from '#lib/server/owner.js';
import { parseSeenBody, setSeen } from '#lib/server/spotting.js';
import type { RequestHandler } from './$types';
/** Owner-only: mark / unmark an entry as seen (collected), optionally with a date and note. */
export const POST: RequestHandler = async (event) => {
if (!isOwner(event)) error(404, 'Not Found');
const body = parseSeenBody(await event.request.json().catch(() => null));
if (!body) error(400, 'Bad request');
const ok = await setSeen('planes', body.id, body.seen, body.seenOn, body.note);
if (!ok) error(404, 'No such entry');
return json({ ok: true, ...body });
};

View File

@@ -1,30 +1,76 @@
import { PAGE_SIZE, parseFilters, searchTrains, trainOverview } from '#lib/server/trains.js';
import {
areasWithData,
groupActivity,
progress,
rankByActivity,
seenByGroup
} from '#lib/server/spotting.js';
import { regionOf } from '#lib/spotting/areas.js';
import type { PageServerLoad } from './$types';
const names = new Intl.DisplayNames(['en'], { type: 'region' });
/** English country names (as stored on operators) inside an area. */
const areaCountries = (area: string) =>
new Set(
(regionOf(area)?.countries ?? [area]).map((c) => {
try {
return names.of(c.toUpperCase()) ?? c;
} catch {
return c;
}
})
);
export const load: PageServerLoad = async ({ url }) => {
const filters = parseFilters(url.searchParams);
const [{ trains, total }, overview] = await Promise.all([searchTrains(filters), trainOverview()]);
const [{ trains, total }, overview, collected, seenMakers, areas, act] = await Promise.all([
searchTrains(filters),
trainOverview(),
progress('trains'),
seenByGroup('trains'),
areasWithData('trains'),
groupActivity('trains', filters.area)
]);
const home = areaCountries(filters.area);
return {
filters,
trains,
total,
pageSize: PAGE_SIZE,
collected,
areas,
makers: overview.makers
.filter((m) => m.types >= 2 || m.slug === filters.maker)
.map(({ slug, name, types }) => ({ slug, name, types })),
countries: overview.countries
.filter((c) => c.types >= 3 || c.name === filters.country)
.map(({ name, types }) => ({ name, types })),
topMakers: overview.byBuilt
topMakers: rankByActivity(overview.byBuilt, act)
.slice(0, 16)
.map(({ slug, name, types, built, logo }) => ({ slug, name, types, built, logo })),
topOperators: overview.operators.slice(0, 16).map(({ slug, name, types, logo }) => ({
slug,
name,
types,
logo,
href: `/trackers/trains/operators/${slug}`
})),
.map(({ id, slug, name, types, built, logo }) => ({
slug,
name,
types,
built,
logo,
seen: seenMakers.get(id) ?? 0
})),
// Operators from the chosen area first (the world region keeps the plain fleet-size order).
topOperators: (filters.area === 'world'
? overview.operators
: [...overview.operators].sort(
(a, b) => Number(home.has(b.country ?? '')) - Number(home.has(a.country ?? ''))
)
)
.slice(0, 16)
.map(({ slug, name, types, logo }) => ({
slug,
name,
types,
logo,
href: `/trackers/trains/operators/${slug}`
})),
stats: overview.stats,
seo: {
title: 'Train pokedex',

View File

@@ -15,6 +15,10 @@
TRACTIONS,
tractionLabel
} from '#lib/trains/shared.js';
import AreaSelect from '#lib/spotting/AreaSelect.svelte';
import { GOT } from '#lib/spotting/areas.js';
import SpotItem from '#lib/spotting/SpotItem.svelte';
import SpotProgress from '#lib/spotting/SpotProgress.svelte';
import { Input } from '#lib/components/ui/input/index.js';
import { NativeSelect } from '#lib/components/ui/native-select/index.js';
import Search from 'lucide-svelte/icons/search';
@@ -27,7 +31,7 @@
const f = $derived(data.filters);
const pages = $derived(Math.max(1, Math.ceil(data.total / data.pageSize)));
const filtering = $derived(
!!(f.q || f.maker || f.category || f.traction || f.country || f.era || f.service)
!!(f.q || f.maker || f.category || f.traction || f.country || f.era || f.service || f.got)
);
let form: HTMLFormElement | undefined = $state();
@@ -61,7 +65,8 @@
{ label: '🚇 metros', params: { category: 'metro', sort: 'built' } },
{ label: '🚋 trams', params: { category: 'tram', sort: 'built' } },
{ label: '💪 most powerful', params: { sort: 'power' } },
{ label: '🟡 VIRM', params: { q: 'virm' } }
{ label: '🟡 VIRM', params: { q: 'virm' } },
{ label: '⬜ still to go', params: { got: 'no' } }
];
</script>
@@ -107,6 +112,15 @@
</div>
</dl>
<div class="mb-8">
<SpotProgress
seen={data.collected.seen}
total={data.collected.total}
label="types collected"
href={withParams({ got: 'no' })}
/>
</div>
{#if !filtering && f.page === 1}
<section class="mb-8" aria-labelledby="makers-heading">
<div class="mb-3 flex items-baseline justify-between gap-3">
@@ -194,6 +208,14 @@
{#each Object.entries(SORTS) as [key, label] (key)}<option value={key}>{label}</option
>{/each}
</NativeSelect>
{#if f.sort === 'active'}<AreaSelect
value={f.area}
available={data.areas}
onchange={submit}
/>{/if}
<NativeSelect name="got" aria-label="Collection" value={f.got} onchange={submit}>
{#each Object.entries(GOT) as [key, label] (key)}<option value={key}>{label}</option>{/each}
</NativeSelect>
<noscript><button type="submit" class="pill">search</button></noscript>
</div>
</form>
@@ -216,7 +238,11 @@
{#if data.trains.length}
<ul class="grid">
{#each data.trains as train, i (train.dex)}
<li><TrainCard {train} eager={i < 4} /></li>
<li>
<SpotItem tracker="trains" id={train.id} seen={train.seen} label={train.fullName}>
<TrainCard {train} eager={i < 4} />
</SpotItem>
</li>
{/each}
</ul>
{:else}

View File

@@ -11,10 +11,17 @@
thumbSrcset
} from '#lib/trains/shared.js';
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
import { page } from '$app/state';
import { parseGot } from '#lib/spotting/areas.js';
import { countSeen, filterGot } from '#lib/spotting/seen.svelte.js';
import SpotItem from '#lib/spotting/SpotItem.svelte';
import SpotProgress from '#lib/spotting/SpotProgress.svelte';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
const b = $derived(data.maker);
const got = $derived(parseGot(page.url.searchParams.get('got')));
const shown = $derived(filterGot('trains', data.types, got));
</script>
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
@@ -61,9 +68,21 @@
</div>
</header>
<div class="mb-6">
<SpotProgress
seen={countSeen('trains', data.types)}
total={data.types.length}
label="{b.name} types collected"
{got}
/>
</div>
<ul class="grid">
{#each data.types as train, i (train.dex)}
<li><TrainCard {train} eager={i < 4} /></li>
{#each shown as train, i (train.dex)}
<li>
<SpotItem tracker="trains" id={train.id} seen={train.seen} label={train.fullName}>
<TrainCard {train} eager={i < 4} />
</SpotItem>
</li>
{/each}
</ul>
<p class="mt-10 text-xs text-[var(--site-muted)]">

View File

@@ -1,6 +1,7 @@
import { error } from '@sveltejs/kit';
import { getType } from '#lib/server/trains.js';
import type { trainTypes } from '#lib/server/db/schema/index.js';
import { getSeen } from '#lib/server/spotting.js';
import type { PageServerLoad } from './$types';
type Type = typeof trainTypes.$inferSelect;
@@ -28,12 +29,14 @@ export const load: PageServerLoad = async ({ params }) => {
const found = await getType(params.maker, params.type);
if (!found) error(404, 'No such train in the pokedex');
const { type, maker, photos, parent, variants, operators, prev, next } = found;
const seenEntry = await getSeen('trains', type.id);
const strip = <T extends { id: number }>(row: T): Omit<T, 'id'> => {
const rest: Partial<T> = { ...row };
delete rest.id;
return rest as Omit<T, 'id'>;
};
return {
spot: { id: type.id, entry: seenEntry },
train: publicType(type),
maker: { slug: maker.slug, name: maker.name, country: maker.country, logo: maker.logo },
photos,

View File

@@ -20,6 +20,7 @@
import ChevronLeft from 'lucide-svelte/icons/chevron-left';
import ChevronRight from 'lucide-svelte/icons/chevron-right';
import Shuffle from 'lucide-svelte/icons/shuffle';
import SeenPanel from '#lib/spotting/SeenPanel.svelte';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
@@ -222,6 +223,7 @@
>{:else if p.inService === false}<span class="chip">retired</span>{/if}
</div>
</header>
<SeenPanel tracker="trains" id={data.spot.id} entry={data.spot.entry} label="this one" />
{#if data.photos.length}
<Gallery photos={data.photos} name={p.fullName} wikipedia={p.wikipedia} />

View File

@@ -9,13 +9,19 @@
type TrainCard as Card
} from '#lib/trains/shared.js';
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
import { page } from '$app/state';
import { parseGot } from '#lib/spotting/areas.js';
import { countSeen, filterGot } from '#lib/spotting/seen.svelte.js';
import SpotItem from '#lib/spotting/SpotItem.svelte';
import SpotProgress from '#lib/spotting/SpotProgress.svelte';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
const got = $derived(parseGot(page.url.searchParams.get('got')));
const groups = $derived(
CATEGORIES.map((cat) => ({
cat,
types: data.types.filter((t: Card) => t.category === cat)
types: filterGot('trains', data.types, got).filter((t: Card) => t.category === cat)
})).filter((g) => g.types.length)
);
</script>
@@ -42,6 +48,14 @@
{/each}
</nav>
</header>
<div class="mb-8">
<SpotProgress
seen={countSeen('trains', data.types)}
total={data.types.length}
label="types collected"
{got}
/>
</div>
{#each groups as g, gi (g.cat)}
<section class="mb-10" id={g.cat.replace(/ /g, '-')}>
@@ -52,7 +66,11 @@
</h2>
<ul class="grid">
{#each g.types as train, i (train.dex)}
<li><TrainCard {train} eager={gi === 0 && i < 4} /></li>
<li>
<SpotItem tracker="trains" id={train.id} seen={train.seen} label={train.fullName}>
<TrainCard {train} eager={gi === 0 && i < 4} />
</SpotItem>
</li>
{/each}
</ul>
</section>

View File

@@ -1,10 +1,12 @@
import { trainOverview } from '#lib/server/trains.js';
import { seenByGroup } from '#lib/server/spotting.js';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
const { byBuilt, stats } = await trainOverview();
const [{ byBuilt, stats }, seen] = await Promise.all([trainOverview(), seenByGroup('trains')]);
return {
makers: byBuilt.map(({ slug, name, types, built, logo }) => ({
makers: byBuilt.map(({ id, slug, name, types, built, logo }) => ({
seen: seen.get(id) ?? 0,
slug,
name,
types,

View File

@@ -10,10 +10,17 @@
thumbSrcset
} from '#lib/trains/shared.js';
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
import { page } from '$app/state';
import { parseGot } from '#lib/spotting/areas.js';
import { countSeen, filterGot } from '#lib/spotting/seen.svelte.js';
import SpotItem from '#lib/spotting/SpotItem.svelte';
import SpotProgress from '#lib/spotting/SpotProgress.svelte';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
const b = $derived(data.operator);
const got = $derived(parseGot(page.url.searchParams.get('got')));
const shown = $derived(filterGot('trains', data.types, got));
</script>
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
@@ -58,9 +65,21 @@
</div>
</header>
<div class="mb-6">
<SpotProgress
seen={countSeen('trains', data.types)}
total={data.types.length}
label="{b.name} types collected"
{got}
/>
</div>
<ul class="grid">
{#each data.types as train, i (train.dex)}
<li><TrainCard {train} eager={i < 4} /></li>
{#each shown as train, i (train.dex)}
<li>
<SpotItem tracker="trains" id={train.id} seen={train.seen} label={train.fullName}>
<TrainCard {train} eager={i < 4} />
</SpotItem>
</li>
{/each}
</ul>
<p class="mt-10 text-xs text-[var(--site-muted)]">

View File

@@ -0,0 +1,14 @@
import { error, json } from '@sveltejs/kit';
import { isOwner } from '#lib/server/owner.js';
import { parseSeenBody, setSeen } from '#lib/server/spotting.js';
import type { RequestHandler } from './$types';
/** Owner-only: mark / unmark an entry as seen (collected), optionally with a date and note. */
export const POST: RequestHandler = async (event) => {
if (!isOwner(event)) error(404, 'Not Found');
const body = parseSeenBody(await event.request.json().catch(() => null));
if (!body) error(400, 'Bad request');
const ok = await setSeen('trains', body.id, body.seen, body.seenOn, body.note);
if (!ok) error(404, 'No such entry');
return json({ ok: true, ...body });
};

View File

@@ -1,18 +1,24 @@
import { PAGE_SIZE, parseFilters, searchSpecies, wildlifeOverview } from '#lib/server/wildlife.js';
import { areasWithData, progress, seenByGroup } from '#lib/server/spotting.js';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ url }) => {
const filters = parseFilters(url.searchParams);
const [{ species, total }, overview] = await Promise.all([
const [{ species, total }, overview, collected, seenGroups, areas] = await Promise.all([
searchSpecies(filters),
wildlifeOverview()
wildlifeOverview(),
progress('wildlife'),
seenByGroup('wildlife'),
areasWithData('wildlife')
]);
return {
filters,
species,
total,
pageSize: PAGE_SIZE,
groups: overview.groups,
collected,
areas,
groups: overview.groups.map((g) => ({ ...g, seen: seenGroups.get(g.id) ?? 0 })),
stats: overview.stats,
seo: {
title: 'Wildlife pokedex',

View File

@@ -7,7 +7,9 @@
let { data }: PageProps = $props();
const f = $derived(data.filters);
const filtering = $derived(!!(f.q || f.group || f.status || f.where || f.sort !== 'nl'));
const filtering = $derived(
!!(f.q || f.group || f.status || f.where || f.got || f.sort !== 'active')
);
const quick: { label: string; params: Record<string, string> }[] = [
{ label: '📖 dex order', params: { sort: 'dex' } },
@@ -79,6 +81,8 @@
pageSize={data.pageSize}
groups={data.groups}
{quick}
areas={data.areas}
collected={data.collected}
/>
<footer class="mt-12 text-xs leading-relaxed text-[var(--site-muted)]">

View File

@@ -1,12 +1,17 @@
import { error } from '@sveltejs/kit';
import { getGroup, PAGE_SIZE, parseFilters, searchSpecies } from '#lib/server/wildlife.js';
import { areasWithData, groupProgress } from '#lib/server/spotting.js';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params, url }) => {
const group = await getGroup(params.group);
if (!group) error(404, 'No such group in the wildlife pokedex');
const filters = parseFilters(url.searchParams, group.slug);
const { species, total } = await searchSpecies(filters);
const [{ species, total }, collected, areas] = await Promise.all([
searchSpecies(filters),
groupProgress('wildlife', group.id),
areasWithData('wildlife')
]);
return {
group: {
slug: group.slug,
@@ -18,6 +23,8 @@ export const load: PageServerLoad = async ({ params, url }) => {
photo: group.photo
},
filters,
collected,
areas,
species,
total,
pageSize: PAGE_SIZE,

View File

@@ -41,6 +41,9 @@
total={data.total}
pageSize={data.pageSize}
{quick}
areas={data.areas}
collected={data.collected}
collectedLabel="{g.name.toLowerCase()} collected"
/>
</div>

View File

@@ -1,17 +1,20 @@
import { error } from '@sveltejs/kit';
import { getSpecies } from '#lib/server/wildlife.js';
import { displayName, photoAt } from '#lib/wildlife/shared.js';
import { getSeen } from '#lib/server/spotting.js';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params }) => {
const found = await getSpecies(params.group, params.species);
if (!found) error(404, 'No such animal in the wildlife pokedex');
const { species: s, group, photos, relatives, prev, next } = found;
const seenEntry = await getSeen('wildlife', s.id);
const name = displayName(s);
const alt = [s.nameNl && s.nameNl !== name ? s.nameNl : null, s.scientificName]
.filter(Boolean)
.join(', ');
return {
spot: { id: s.id, entry: seenEntry },
species: {
dex: s.dex,
slug: s.slug,

View File

@@ -14,6 +14,7 @@
import ChevronLeft from 'lucide-svelte/icons/chevron-left';
import ChevronRight from 'lucide-svelte/icons/chevron-right';
import Shuffle from 'lucide-svelte/icons/shuffle';
import SeenPanel from '#lib/spotting/SeenPanel.svelte';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
@@ -165,6 +166,7 @@
{#if s.extinct}<span class="chip">🪦 extinct</span>{/if}
</div>
</header>
<SeenPanel tracker="wildlife" id={data.spot.id} entry={data.spot.entry} label="this one" />
{#if data.photos.length}
<Gallery photos={data.photos} {name} />

View File

@@ -1,10 +1,14 @@
import { wildlifeOverview } from '#lib/server/wildlife.js';
import { seenByGroup } from '#lib/server/spotting.js';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
const { groups, stats } = await wildlifeOverview();
const [{ groups, stats }, seen] = await Promise.all([
wildlifeOverview(),
seenByGroup('wildlife')
]);
return {
groups,
groups: groups.map((g) => ({ ...g, seen: seen.get(g.id) ?? 0 })),
stats,
seo: {
title: 'Wildlife groups',

View File

@@ -0,0 +1,14 @@
import { error, json } from '@sveltejs/kit';
import { isOwner } from '#lib/server/owner.js';
import { parseSeenBody, setSeen } from '#lib/server/spotting.js';
import type { RequestHandler } from './$types';
/** Owner-only: mark / unmark an entry as seen (collected), optionally with a date and note. */
export const POST: RequestHandler = async (event) => {
if (!isOwner(event)) error(404, 'Not Found');
const body = parseSeenBody(await event.request.json().catch(() => null));
if (!body) error(400, 'Bad request');
const ok = await setSeen('wildlife', body.id, body.seen, body.seenOn, body.note);
if (!ok) error(404, 'No such entry');
return json({ ok: true, ...body });
};