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

@@ -6,6 +6,7 @@
</script>
<a href="/trackers/cars/{brand.slug}" class={['tile', { compact }]} title={brand.name}>
{#if brand.seen}<span class="got" title="{brand.seen} collected">✓ {brand.seen}</span>{/if}
<span class="mark"><BrandBadge name={brand.name} logo={brand.logo} size="xl" /></span>
<span class="name">{brand.name}</span>
{#if !compact}
@@ -18,7 +19,20 @@
</a>
<style>
.got {
position: absolute;
top: 0.4rem;
right: 0.4rem;
z-index: 1;
border-radius: 999px;
padding: 0.05rem 0.4rem;
font-size: 0.68rem;
font-weight: 600;
color: #fff;
background: rgb(22 163 74);
}
.tile {
position: relative;
display: flex;
flex-direction: column;
align-items: center;

View File

@@ -24,6 +24,7 @@ export const BODIES = [
] as const;
export const DRIVES = ['fwd', 'rwd', 'awd', '4wd'] as const;
export const SORTS = {
active: 'most active in…',
popular: 'most common in NL',
dex: 'dex #',
name: 'name',
@@ -152,6 +153,8 @@ export const years = (first: number | null, last: number | null, current: number
: `${first}–${last >= current ? 'now' : last}`;
export type CarCard = {
id: number;
seen: boolean;
dex: number;
slug: string;
name: string;
@@ -190,6 +193,8 @@ export type CarPhoto = {
/** A brand in the logo grid (/trackers/cars/brands and the strip on /trackers/cars). */
export type BrandTileData = {
/** Entries Stan has collected in this group (optional). */
seen?: number;
slug: string;
name: string;
models: number;

View File

@@ -6,6 +6,7 @@
</script>
<a href="/trackers/planes/{maker.slug}" class={['tile', { compact }]} title={maker.name}>
{#if maker.seen}<span class="got" title="{maker.seen} collected">✓ {maker.seen}</span>{/if}
<span class="mark"><BrandBadge name={maker.name} logo={maker.logo} size="xl" /></span>
<span class="name">{maker.name}</span>
{#if !compact}
@@ -18,7 +19,20 @@
</a>
<style>
.got {
position: absolute;
top: 0.4rem;
right: 0.4rem;
z-index: 1;
border-radius: 999px;
padding: 0.05rem 0.4rem;
font-size: 0.68rem;
font-weight: 600;
color: #fff;
background: rgb(22 163 74);
}
.tile {
position: relative;
display: flex;
flex-direction: column;
align-items: center;

View File

@@ -41,6 +41,7 @@ export const ERAS = {
} as const;
export type PlaneEra = keyof typeof ERAS;
export const SORTS = {
active: 'most active in…',
built: 'most built',
dex: 'dex #',
name: 'name',
@@ -148,6 +149,8 @@ export const fmtKg = (kg: number) =>
export const fmtM = (m: number) => `${m >= 100 ? Math.round(m) : Math.round(m * 10) / 10} m`;
export type PlaneCard = {
id: number;
seen: boolean;
dex: number;
slug: string;
name: string;
@@ -170,6 +173,8 @@ export type PlaneCard = {
};
export type MakerTileData = {
/** Entries Stan has collected in this group (optional). */
seen?: number;
slug: string;
name: string;
types: number;

View File

@@ -4,6 +4,8 @@ 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';
import { parseArea, parseGot, type Got } from '#lib/spotting/areas.js';
import { activitySql, gotSql, seenSql } from '#lib/server/spotting.js';
export const PAGE_SIZE = 60;
@@ -17,6 +19,9 @@ export type CarFilters = {
from: number | null;
to: number | null;
sort: CarSort;
/** Area for the "most active" sort (?in=, default NL). */
area: string;
got: Got;
page: number;
};
@@ -38,12 +43,16 @@ export function parseFilters(params: URLSearchParams): CarFilters {
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',
sort: sort && sort in SORTS ? (sort as CarSort) : 'active',
area: parseArea(params.get('in')),
got: parseGot(params.get('got')),
page: Math.min(Math.max(1, Math.floor(Number(params.get('page')) || 1)), 1000)
};
}
const cardColumns = {
id: carModels.id,
seen: seenSql('cars', carModels.id),
dex: carModels.dex,
slug: carModels.slug,
name: carModels.name,
@@ -74,7 +83,15 @@ export async function searchCars(f: CarFilters): Promise<{ cars: CarCard[]; tota
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 got = gotSql('cars', carModels.id, f.got);
if (got) where.push(got);
const order = {
active: [
sql`${activitySql('cars', carModels.id, f.area)} desc`,
desc(carModels.nlCount),
desc(carModels.variantCount),
asc(carModels.dex)
],
dex: [asc(carModels.dex)],
popular: [desc(carModels.nlCount), asc(carModels.dex)],
name: [asc(carModels.name), asc(carBrands.name)],
@@ -104,6 +121,7 @@ export async function searchCars(f: CarFilters): Promise<{ cars: CarCard[]; tota
}
export type BrandTile = {
id: number;
slug: string;
name: string;
models: number;
@@ -125,6 +143,7 @@ export async function carOverview(): Promise<Overview> {
const [brands, [counts]] = await Promise.all([
db
.select({
id: carBrands.id,
slug: carBrands.slug,
name: carBrands.name,
models: carBrands.modelCount,

View File

@@ -12,6 +12,8 @@ import {
type PlaneEra,
type PlaneSort
} from '#lib/planes/shared.js';
import { parseArea, parseGot, type Got } from '#lib/spotting/areas.js';
import { activitySql, gotSql, seenSql } from '#lib/server/spotting.js';
export const PAGE_SIZE = 60;
@@ -23,6 +25,9 @@ export type PlaneFilters = {
era: PlaneEra | '';
service: '' | 'yes' | 'no';
sort: PlaneSort;
/** Area for the "most active" sort (?in=, default NL). */
area: string;
got: Got;
page: number;
};
@@ -38,12 +43,16 @@ export function parseFilters(params: URLSearchParams): PlaneFilters {
engine: pick(params.get('engine'), ENGINES),
era: pick(params.get('era'), Object.keys(ERAS) as PlaneEra[]),
service: pick(params.get('service'), ['yes', 'no'] as const),
sort: sort && sort in SORTS ? (sort as PlaneSort) : 'built',
sort: sort && sort in SORTS ? (sort as PlaneSort) : 'active',
area: parseArea(params.get('in')),
got: parseGot(params.get('got')),
page: Math.min(Math.max(1, Math.floor(Number(params.get('page')) || 1)), 1000)
};
}
const cardColumns = {
id: planeTypes.id,
seen: seenSql('planes', planeTypes.id),
dex: planeTypes.dex,
slug: planeTypes.slug,
name: planeTypes.name,
@@ -89,7 +98,14 @@ export async function searchPlanes(
}
if (f.service === 'yes') where.push(eq(planeTypes.inService, true));
if (f.service === 'no') where.push(eq(planeTypes.inService, false));
const got = gotSql('planes', planeTypes.id, f.got);
if (got) where.push(got);
const order = {
active: [
sql`${activitySql('planes', planeTypes.id, f.area)} desc`,
sql`${planeTypes.numberBuilt} desc nulls last`,
asc(planeTypes.dex)
],
built: [sql`${planeTypes.numberBuilt} desc nulls last`, asc(planeTypes.dex)],
dex: [asc(planeTypes.dex)],
name: [asc(planeTypes.fullName)],
@@ -120,6 +136,7 @@ export async function searchPlanes(
}
export type MakerTile = {
id: number;
slug: string;
name: string;
types: number;
@@ -141,6 +158,7 @@ export async function planeOverview(): Promise<Overview> {
const [makers, [counts]] = await Promise.all([
db
.select({
id: planeMakers.id,
slug: planeMakers.slug,
name: planeMakers.name,
types: planeMakers.typeCount,

164
src/lib/server/spotting.ts Normal file
View File

@@ -0,0 +1,164 @@
// Shared server helpers for the four spotting pokedexes (cars, planes, trains, wildlife):
// Stan's collection (<schema>.seen) and the per-country activity areas (<schema>.activity,
// filled by scripts/spotters-activity.ts). Only the owner writes `seen`; everyone can read.
import { client } from '#lib/server/db/index.js';
import { isRegion } from '#lib/spotting/areas.js';
export type Tracker = 'cars' | 'planes' | 'trains' | 'wildlife';
/** Constant identifiers only (never user input), so building SQL text from them is safe. */
const T: Record<Tracker, { items: string; col: string; group: string }> = {
cars: { items: 'cars.models', col: 'model_id', group: 'brand_id' },
planes: { items: 'planes.types', col: 'type_id', group: 'maker_id' },
trains: { items: 'trains.types', col: 'type_id', group: 'maker_id' },
wildlife: { items: 'wildlife.species', col: 'species_id', group: 'group_id' }
};
export const isTracker = (t: string): t is Tracker => t in T;
export type SeenEntry = { seenOn: string | null; note: string | null; at: string };
/** Overall progress: seen / total. */
export async function progress(tracker: Tracker): Promise<{ seen: number; total: number }> {
const { items, col } = T[tracker];
const schema = tracker;
const [r] = await client.unsafe<{ seen: number; total: number }[]>(
`select (select count(*)::int from ${schema}.seen s join ${items} i on i.id = s.${col}) as seen,
(select count(*)::int from ${items}) as total`
);
return r;
}
/** Seen count per group (brand / maker / animal group id). */
export async function seenByGroup(tracker: Tracker): Promise<Map<number, number>> {
const { items, col, group } = T[tracker];
const rows = await client.unsafe<{ g: number; n: number }[]>(
`select i.${group} as g, count(*)::int as n from ${tracker}.seen s
join ${items} i on i.id = s.${col} group by 1`
);
return new Map(rows.map((r) => [r.g, r.n]));
}
/** Seen / total for one group. */
export async function groupProgress(tracker: Tracker, groupId: number) {
const { items, col, group } = T[tracker];
const [r] = await client.unsafe<{ seen: number; total: number }[]>(
`select count(s.${col})::int as seen, count(*)::int as total
from ${items} i left join ${tracker}.seen s on s.${col} = i.id where i.${group} = $1`,
[groupId]
);
return r;
}
/** Seen / total for an arbitrary set of item ids (e.g. a train operator's fleet). */
export async function idsProgress(tracker: Tracker, ids: number[]) {
if (!ids.length) return { seen: 0, total: 0 };
const { col } = T[tracker];
const [r] = await client.unsafe<{ seen: number }[]>(
`select count(*)::int as seen from ${tracker}.seen where ${col} = any($1::int[])`,
[ids]
);
return { seen: r.seen, total: new Set(ids).size };
}
export async function getSeen(tracker: Tracker, id: number): Promise<SeenEntry | null> {
const { col } = T[tracker];
const [r] = await client.unsafe<{ seen_on: string | null; note: string | null; at: Date }[]>(
`select seen_on::text, note, created_at as at from ${tracker}.seen where ${col} = $1`,
[id]
);
return r ? { seenOn: r.seen_on, note: r.note, at: r.at.toISOString() } : null;
}
/** Marks (or unmarks) an item as seen. Returns false when the item does not exist. */
export async function setSeen(
tracker: Tracker,
id: number,
seen: boolean,
seenOn: string | null = null,
note: string | null = null
): Promise<boolean> {
const { items, col } = T[tracker];
const [exists] = await client.unsafe(`select 1 from ${items} where id = $1`, [id]);
if (!exists) return false;
if (!seen) {
await client.unsafe(`delete from ${tracker}.seen where ${col} = $1`, [id]);
return true;
}
await client.unsafe(
`insert into ${tracker}.seen (${col}, seen_on, note) values ($1, $2::date, $3)
on conflict (${col}) do update set seen_on = excluded.seen_on, note = excluded.note`,
[id, seenOn, note]
);
return true;
}
const areaCache = new Map<Tracker, { at: number; value: Set<string> }>();
/** Areas that have any activity rows (cached 10 min), to grey out empty choices. */
export async function areasWithData(tracker: Tracker): Promise<string[]> {
const hit = areaCache.get(tracker);
if (hit && Date.now() - hit.at < 600_000) return [...hit.value];
const rows = await client.unsafe<{ area: string }[]>(
`select distinct area from ${tracker}.activity`
);
const value = new Set(rows.map((r) => r.area));
if (value.size) areaCache.set(tracker, { at: Date.now(), value });
return [...value];
}
/** Validates a POSTed seen change. */
export function parseSeenBody(body: unknown) {
const b = (body ?? {}) as Record<string, unknown>;
const id = Number(b.id);
if (!Number.isInteger(id) || id <= 0) return null;
const seen = b.seen !== false;
const seenOn =
typeof b.seenOn === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(b.seenOn) ? b.seenOn : null;
const note = typeof b.note === 'string' && b.note.trim() ? b.note.trim().slice(0, 500) : null;
return { id, seen, seenOn, note };
}
export { isRegion };
// ---- SQL fragments for the drizzle list queries ----
import { sql, type AnyColumn, type SQL } from 'drizzle-orm';
/** `exists(seen)` for a card column. */
export const seenSql = (tracker: Tracker, idCol: AnyColumn) =>
sql<boolean>`exists(select 1 from ${sql.raw(`${tracker}.seen`)} s where s.${sql.raw(T[tracker].col)} = ${idCol})`;
/** Activity score of an item in an area (0 when unknown), for the "most active" sort. */
export const activitySql = (tracker: Tracker, idCol: AnyColumn, area: string) =>
sql<number>`coalesce((select a.score from ${sql.raw(`${tracker}.activity`)} a where a.${sql.raw(T[tracker].col)} = ${idCol} and a.area = ${area}), 0)`;
/** ?got=yes|no filter. */
export function gotSql(tracker: Tracker, idCol: AnyColumn, got: string): SQL | null {
if (got === 'yes') return seenSql(tracker, idCol);
if (got === 'no') return sql`not ${seenSql(tracker, idCol)}`;
return null;
}
const groupActCache = new Map<string, { at: number; value: Map<number, number> }>();
/** Summed activity of each group's entries in an area (cached 10 min): ranks brand/maker tiles. */
export async function groupActivity(tracker: Tracker, area: string): Promise<Map<number, number>> {
const k = `${tracker}:${area}`;
const hit = groupActCache.get(k);
if (hit && Date.now() - hit.at < 600_000) return hit.value;
const { items, col, group } = T[tracker];
const rows = await client.unsafe<{ g: number; s: number }[]>(
`select i.${group} as g, sum(a.score)::float8 as s from ${tracker}.activity a
join ${items} i on i.id = a.${col} where a.area = $1 group by 1`,
[area]
);
const value = new Map(rows.map((r) => [r.g, r.s]));
groupActCache.set(k, { at: Date.now(), value });
return value;
}
/** Orders tiles by area activity (then their existing order). */
export function rankByActivity<T extends { id: number }>(tiles: T[], act: Map<number, number>) {
if (!act.size) return tiles;
return tiles
.map((t, i) => ({ t, i, s: act.get(t.id) ?? 0 }))
.sort((a, b) => b.s - a.s || a.i - b.i)
.map((x) => x.t);
}

View File

@@ -19,6 +19,8 @@ import {
type TrainEra,
type TrainSort
} from '#lib/trains/shared.js';
import { parseArea, parseGot, type Got } from '#lib/spotting/areas.js';
import { activitySql, gotSql, seenSql } from '#lib/server/spotting.js';
export const PAGE_SIZE = 60;
@@ -31,6 +33,9 @@ export type TrainFilters = {
era: TrainEra | '';
service: '' | 'yes' | 'no';
sort: TrainSort;
/** Area for the "most active" sort (?in=, default NL). */
area: string;
got: Got;
page: number;
};
@@ -47,12 +52,16 @@ export function parseFilters(params: URLSearchParams): TrainFilters {
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',
sort: sort && sort in SORTS ? (sort as TrainSort) : 'active',
area: parseArea(params.get('in')),
got: parseGot(params.get('got')),
page: Math.min(Math.max(1, Math.floor(Number(params.get('page')) || 1)), 1000)
};
}
const cardColumns = {
id: trainTypes.id,
seen: seenSql('trains', trainTypes.id),
dex: trainTypes.dex,
slug: trainTypes.slug,
name: trainTypes.name,
@@ -73,8 +82,13 @@ const cardColumns = {
};
const escapeLike = (s: string) => s.replace(/[\\%_]/g, (c) => `\\${c}`);
const order = (sort: TrainSort) =>
const order = (sort: TrainSort, area = 'nl') =>
({
active: [
sql`${activitySql('trains', trainTypes.id, area)} desc`,
sql`${trainTypes.numberBuilt} desc nulls last`,
asc(trainTypes.dex)
],
built: [sql`${trainTypes.numberBuilt} desc nulls last`, asc(trainTypes.dex)],
dex: [asc(trainTypes.dex)],
name: [asc(trainTypes.fullName)],
@@ -107,6 +121,8 @@ export async function searchTrains(
}
if (f.service === 'yes') where.push(eq(trainTypes.inService, true));
if (f.service === 'no') where.push(eq(trainTypes.inService, false));
const got = gotSql('trains', trainTypes.id, f.got);
if (got) where.push(got);
const condition = where.length ? and(...where) : undefined;
const [trains, [{ total }]] = await Promise.all([
db
@@ -114,7 +130,7 @@ export async function searchTrains(
.from(trainTypes)
.innerJoin(trainMakers, eq(trainMakers.id, trainTypes.makerId))
.where(condition)
.orderBy(...order(f.sort))
.orderBy(...order(f.sort, f.area))
.limit(PAGE_SIZE)
.offset((f.page - 1) * PAGE_SIZE),
db
@@ -127,6 +143,7 @@ export async function searchTrains(
}
export type MakerTile = {
id: number;
slug: string;
name: string;
types: number;
@@ -165,6 +182,7 @@ export async function trainOverview(): Promise<Overview> {
const [makers, operators, countries, [counts]] = await Promise.all([
db
.select({
id: trainMakers.id,
slug: trainMakers.slug,
name: trainMakers.name,
types: trainMakers.typeCount,

View File

@@ -11,6 +11,8 @@ import {
type WildSort,
type WildWhere
} from '#lib/wildlife/shared.js';
import { parseArea, parseGot, type Got } from '#lib/spotting/areas.js';
import { activitySql, gotSql, seenSql } from '#lib/server/spotting.js';
export const PAGE_SIZE = 60;
@@ -21,6 +23,9 @@ export type WildFilters = {
where: WildWhere | '';
photos: '' | 'yes';
sort: WildSort;
/** Area for the "most active" sort (?in=, default NL). */
area: string;
got: Got;
page: number;
};
@@ -36,12 +41,16 @@ export function parseFilters(params: URLSearchParams, group = ''): WildFilters {
status: status === 'threatened' ? status : pick(status, STATUSES),
where: pick(params.get('where'), Object.keys(WHERE) as WildWhere[]),
photos: pick(params.get('photos'), ['yes'] as const),
sort: sort && sort in SORTS ? (sort as WildSort) : 'nl',
sort: sort && sort in SORTS ? (sort as WildSort) : 'active',
area: parseArea(params.get('in')),
got: parseGot(params.get('got')),
page: Math.min(Math.max(1, Math.floor(Number(params.get('page')) || 1)), 1000)
};
}
const cardColumns = {
id: wildlifeSpecies.id,
seen: seenSql('wildlife', wildlifeSpecies.id),
dex: wildlifeSpecies.dex,
slug: wildlifeSpecies.slug,
groupSlug: wildlifeGroups.slug,
@@ -77,7 +86,15 @@ export async function searchSpecies(
if (f.where === 'world')
where.push(and(eq(wildlifeSpecies.nlObs, 0), eq(wildlifeSpecies.europeObs, 0))!);
if (f.photos) where.push(isNotNull(wildlifeSpecies.photo));
const got = gotSql('wildlife', wildlifeSpecies.id, f.got);
if (got) where.push(got);
const order = {
active: [
sql`${activitySql('wildlife', wildlifeSpecies.id, f.area)} desc`,
sql`${wildlifeSpecies.nlObs} desc`,
sql`${wildlifeSpecies.worldObs} desc`,
asc(wildlifeSpecies.dex)
],
nl: [
sql`${wildlifeSpecies.nlObs} desc`,
sql`${wildlifeSpecies.worldObs} desc`,
@@ -114,6 +131,7 @@ export async function searchSpecies(
type Overview = {
groups: {
id: number;
slug: string;
name: string;
nameNl: string;
@@ -132,6 +150,7 @@ export async function wildlifeOverview(): Promise<Overview> {
const [groups, [counts]] = await Promise.all([
db
.select({
id: wildlifeGroups.id,
slug: wildlifeGroups.slug,
name: wildlifeGroups.name,
nameNl: wildlifeGroups.nameNl,

View File

@@ -0,0 +1,31 @@
<script lang="ts">
// "most active in …" selector (?in=): countries and regions with precomputed scores.
import { NativeSelect } from '#lib/components/ui/native-select/index.js';
import { AREA_OPTIONS, areaLabel } from './areas.js';
let {
value,
available = [],
onchange
}: { value: string; available?: string[]; onchange?: () => void } = $props();
const has = $derived(new Set(available));
const known = $derived(
new Set([...AREA_OPTIONS.countries, ...AREA_OPTIONS.regions].map((o) => o.code))
);
</script>
<NativeSelect name="in" aria-label="Most active in country or region" {value} {onchange}>
{#if !known.has(value)}<option {value}>📍 {areaLabel(value)}</option>{/if}
<optgroup label="Country">
{#each AREA_OPTIONS.countries as o (o.code)}
<option value={o.code} disabled={available.length > 0 && !has.has(o.code)}
>📍 {o.label}</option
>
{/each}
</optgroup>
<optgroup label="Region">
{#each AREA_OPTIONS.regions as o (o.code)}
<option value={o.code} disabled={available.length > 0 && !has.has(o.code)}>{o.label}</option>
{/each}
</optgroup>
</NativeSelect>

View File

@@ -0,0 +1,154 @@
<script lang="ts">
// Detail-page collection panel: the owner marks an entry as seen with an optional date
// and note; visitors see Stan's sighting read-only.
import { page } from '$app/state';
import { isSeen, saveSeen, type SpotTracker } from './seen.svelte.js';
let {
tracker,
id,
entry,
label
}: {
tracker: SpotTracker;
id: number;
entry: { seenOn: string | null; note: string | null } | null;
label: string;
} = $props();
const owner = $derived(!!page.data.isOwner);
const on = $derived(isSeen(tracker, id, !!entry));
const today = () => new Date().toISOString().slice(0, 10);
let seenOn = $state('');
let note = $state('');
let busy = $state(false);
let failed = $state(false);
$effect.pre(() => {
seenOn = entry?.seenOn ?? '';
note = entry?.note ?? '';
});
async function save(seen: boolean) {
busy = true;
failed = false;
const ok = await saveSeen(tracker, id, seen, {
seenOn: seen ? seenOn || (entry ? null : today()) : null,
note: seen ? note : null
});
failed = !ok;
busy = false;
}
</script>
{#if owner}
<section class={['panel', { on }]} aria-label="My collection">
<div class="head">
<strong>{on ? '✅ Collected' : '⬜ Not collected yet'}</strong>
<button type="button" class="btn primary" disabled={busy} onclick={() => save(!on)}>
{on ? 'Unmark' : `Mark ${label} as seen`}
</button>
</div>
{#if on}
<form
class="fields"
onsubmit={(e) => {
e.preventDefault();
save(true);
}}
>
<label>
<span>seen on</span>
<input type="date" bind:value={seenOn} max={today()} />
</label>
<label class="grow">
<span>note</span>
<input type="text" bind:value={note} maxlength="500" placeholder="where, who with…" />
</label>
<button type="submit" class="btn" disabled={busy}>save</button>
</form>
{/if}
{#if failed}<p class="err">Couldn't save. Are you still logged in?</p>{/if}
</section>
{:else if on && entry}
<section class="panel on" aria-label="Stan's collection">
<strong>✅ Stan has seen this one</strong>{#if entry.seenOn}
<span class="muted"> on {entry.seenOn}</span>{/if}{#if entry.note}
<p class="muted mt-1">“{entry.note}”</p>{/if}
</section>
{/if}
<style>
.panel {
border: 1px solid var(--site-border);
border-radius: 0.8rem;
background: var(--site-surface);
padding: 0.75rem 0.95rem;
margin: 1rem 0;
font-size: 0.9rem;
}
.panel.on {
border-color: rgb(34 197 94 / 0.6);
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
}
.fields {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
align-items: flex-end;
margin-top: 0.7rem;
}
.fields label {
display: flex;
flex-direction: column;
gap: 0.2rem;
font-size: 0.75rem;
color: var(--site-muted);
}
.fields .grow {
flex: 1 1 12rem;
}
.fields input {
border: 1px solid var(--site-border);
border-radius: 0.45rem;
background: var(--site-bg, transparent);
color: var(--site-fg, inherit);
padding: 0.35rem 0.55rem;
font-size: 0.85rem;
}
.btn {
border: 1px solid var(--site-border);
border-radius: 999px;
padding: 0.3rem 0.85rem;
font-size: 0.82rem;
font-weight: 500;
background: var(--site-surface);
}
.btn:hover {
background: var(--site-hover);
}
.btn.primary {
background: rgb(22 163 74);
border-color: rgb(22 163 74);
color: #fff;
}
.on .btn.primary {
background: var(--site-surface);
border-color: var(--site-border);
color: inherit;
}
.btn:disabled {
opacity: 0.6;
}
.muted {
color: var(--site-muted);
}
.err {
margin-top: 0.5rem;
color: rgb(239 68 68);
font-size: 0.8rem;
}
</style>

View File

@@ -0,0 +1,88 @@
<script lang="ts">
// Wraps a dex card: a ✓ badge when Stan has seen it, and for the owner a toggle button
// (kept outside the card's <a> so it stays valid, focusable HTML).
import { page } from '$app/state';
import type { Snippet } from 'svelte';
import { isSeen, saveSeen, type SpotTracker } from './seen.svelte.js';
let {
tracker,
id,
seen = false,
label,
children
}: {
tracker: SpotTracker;
id: number;
seen?: boolean;
label: string;
children: Snippet;
} = $props();
const owner = $derived(!!page.data.isOwner);
const on = $derived(isSeen(tracker, id, seen));
let busy = $state(false);
async function toggle() {
busy = true;
await saveSeen(tracker, id, !on);
busy = false;
}
</script>
<div class={['spot', { on }]}>
{@render children()}
{#if owner}
<button
type="button"
class="mark"
aria-pressed={on}
disabled={busy}
onclick={toggle}
title={on ? `Collected. Click to unmark ${label}` : `Mark ${label} as seen`}
>{on ? '✓' : '+'}<span class="sr-only">{on ? 'collected' : 'mark as seen'}</span></button
>
{:else if on}
<span class="mark" title="Stan has seen this one">✓<span class="sr-only">collected</span></span>
{/if}
</div>
<style>
.spot {
position: relative;
height: 100%;
}
.spot.on > :global(a) {
box-shadow: 0 0 0 2px rgb(34 197 94 / 0.75);
}
.mark {
position: absolute;
top: 0.5rem;
left: 0.5rem;
z-index: 2;
display: grid;
place-items: center;
width: 1.9rem;
height: 1.9rem;
border-radius: 999px;
font-size: 1rem;
font-weight: 700;
line-height: 1;
color: #fff;
background: rgb(0 0 0 / 0.55);
border: 1.5px solid rgb(255 255 255 / 0.7);
backdrop-filter: blur(4px);
transition:
background 0.15s,
transform 0.15s;
}
button.mark:hover {
transform: scale(1.08);
background: rgb(0 0 0 / 0.75);
}
.on .mark {
background: rgb(22 163 74);
border-color: rgb(187 247 208);
}
button.mark:disabled {
opacity: 0.6;
}
</style>

View File

@@ -0,0 +1,98 @@
<script lang="ts">
// "✅ 12 / 3,694 collected" with a bar; read-only for visitors.
import { page } from '$app/state';
import ProgressBar from '#lib/components/ProgressBar.svelte';
import { GOT } from './areas.js';
let {
seen,
total,
label = 'collected',
href = null,
got = null
}: {
seen: number;
total: number;
label?: string;
href?: string | null;
/** When set, shows all / collected / still-to-go chips that set ?got= on this page. */
got?: string | null;
} = $props();
const gotHref = (value: string) => {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- throwaway copy for a link
const params = new URLSearchParams(page.url.searchParams.toString());
if (value) params.set('got', value);
else params.delete('got');
const qs = params.toString();
return `${page.url.pathname}${qs ? `?${qs}` : ''}`;
};
const pct = $derived(total ? Math.round((seen / total) * 1000) / 10 : 0);
</script>
<div class="progress">
<div class="row">
<span
>✅ <strong class="tabular-nums">{seen.toLocaleString('en-GB')}</strong>
<span class="muted">/ {total.toLocaleString('en-GB')} {label}</span></span
>
<span class="muted tabular-nums"
>{pct}%{#if href}
· <a {href} class="underline">to go →</a>{/if}</span
>
</div>
<ProgressBar value={seen} max={total} label="{seen} of {total} {label}" small />
{#if got !== null}
<div class="chips">
{#each Object.entries(GOT) as [key, text] (key)}
<a
href={gotHref(key)}
class={['chip', { active: got === key }]}
aria-current={got === key ? 'true' : undefined}
data-sveltekit-noscroll
data-sveltekit-replacestate>{text}</a
>
{/each}
</div>
{/if}
</div>
<style>
.progress {
display: flex;
flex-direction: column;
gap: 0.35rem;
border: 1px solid var(--site-border);
border-radius: 0.75rem;
background: var(--site-surface);
padding: 0.65rem 0.9rem;
}
.row {
display: flex;
justify-content: space-between;
gap: 0.75rem;
font-size: 0.9rem;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-top: 0.15rem;
}
.chip {
border: 1px solid var(--site-border);
border-radius: 999px;
padding: 0.1rem 0.6rem;
font-size: 0.78rem;
}
.chip:hover {
background: var(--site-hover);
}
.chip.active {
border-color: var(--site-muted);
background: var(--site-hover);
font-weight: 600;
}
.muted {
color: var(--site-muted);
}
</style>

277
src/lib/spotting/areas.ts Normal file
View File

@@ -0,0 +1,277 @@
// Countries and regions for the spotting pokedexes' "most active in ..." sort
// (/trackers/cars, planes, trains, wildlife). Codes are lowercase ISO 3166-1 alpha-2
// ("nl") or a region code ("europe"). Scores per area are precomputed into each
// tracker's `activity` table by scripts/spotters-activity.ts; a region's score is the sum
// of its countries' scores (wildlife: iNaturalist's own Europe / world counts).
export const DEFAULT_AREA = 'nl';
export type Region = { code: string; name: string; emoji: string; countries: readonly string[] };
const EUROPE = [
'nl',
'be',
'lu',
'de',
'fr',
'gb',
'ie',
'es',
'pt',
'it',
'ch',
'at',
'dk',
'se',
'no',
'fi',
'is',
'pl',
'cz',
'sk',
'hu',
'si',
'hr',
'ba',
'rs',
'me',
'mk',
'al',
'gr',
'bg',
'ro',
'md',
'ua',
'by',
'lt',
'lv',
'ee',
'ru',
'mt',
'cy',
'li',
'mc',
'sm',
'ad',
'im',
'je',
'gg',
'xk',
'tr',
'ge',
'am',
'az'
] as const;
export const REGIONS: readonly Region[] = [
{ code: 'benelux', name: 'Benelux', emoji: '🧇', countries: ['nl', 'be', 'lu'] },
{ code: 'europe', name: 'Europe', emoji: '🇪🇺', countries: EUROPE },
{
code: 'north-america',
name: 'North America',
emoji: '🌎',
countries: ['us', 'ca', 'mx', 'cu', 'gt', 'cr', 'pa', 'jm', 'do', 'hn', 'sv', 'ni', 'bs', 'pr']
},
{
code: 'south-america',
name: 'South America',
emoji: '🌎',
countries: ['br', 'ar', 'cl', 'co', 'pe', 've', 'ec', 'bo', 'py', 'uy', 'gy', 'sr']
},
{
code: 'asia',
name: 'Asia',
emoji: '🌏',
countries: [
'jp',
'cn',
'kr',
'kp',
'tw',
'hk',
'in',
'id',
'th',
'vn',
'my',
'sg',
'ph',
'pk',
'bd',
'lk',
'mm',
'kh',
'la',
'np',
'mn',
'kz',
'uz',
'tm',
'kg',
'tj',
'ir',
'iq',
'sa',
'ae',
'il',
'jo',
'sy',
'lb',
'qa',
'kw',
'om',
'bh',
'ye',
'af',
'mo',
'bt',
'bn'
]
},
{
code: 'africa',
name: 'Africa',
emoji: '🌍',
countries: [
'za',
'eg',
'ma',
'dz',
'tn',
'ly',
'ng',
'ke',
'tz',
'ug',
'et',
'gh',
'zw',
'zm',
'mz',
'na',
'bw',
'ao',
'cd',
'cg',
'cm',
'sn',
'ci',
'sd',
'mg',
'mw',
'rw',
'sz',
'ls',
'er',
'sl',
'ga',
'ml',
'ne',
'bf',
'td',
'so',
'mu'
]
},
{
code: 'oceania',
name: 'Oceania',
emoji: '🦘',
countries: ['au', 'nz', 'pg', 'fj', 'nc', 'pf', 'sb', 'vu', 'ws', 'to']
},
{ code: 'world', name: 'the whole world', emoji: '🌐', countries: [] }
];
/** Countries offered in the selector (any ISO code with data also works via ?in=xx). */
export const COUNTRIES = [
'nl',
'be',
'de',
'fr',
'gb',
'ie',
'es',
'pt',
'it',
'ch',
'at',
'dk',
'se',
'no',
'fi',
'pl',
'cz',
'hu',
'gr',
'tr',
'ru',
'ua',
'us',
'ca',
'mx',
'br',
'ar',
'jp',
'cn',
'kr',
'tw',
'in',
'id',
'th',
'au',
'nz',
'za'
] as const;
const regionByCode = new Map(REGIONS.map((r) => [r.code, r]));
export const isRegion = (code: string) => regionByCode.has(code);
export const regionOf = (code: string) => regionByCode.get(code) ?? null;
let names: Intl.DisplayNames | null = null;
/** "nl" -> "the Netherlands", "europe" -> "Europe". */
export function areaName(code: string): string {
const region = regionByCode.get(code);
if (region) return region.name;
if (code === 'nl') return 'the Netherlands';
if (code === 'us') return 'the USA';
if (code === 'gb') return 'the UK';
try {
names ??= new Intl.DisplayNames(['en'], { type: 'region' });
return names.of(code.toUpperCase()) ?? code.toUpperCase();
} catch {
return code.toUpperCase();
}
}
/** Short label for chips and selects: "🇳🇱 Netherlands", "🇪🇺 Europe". */
export function areaLabel(code: string): string {
const region = regionByCode.get(code);
if (region) return `${region.emoji} ${code === 'world' ? 'World' : region.name}`;
return `${flagOf(code)} ${areaName(code).replace(/^the /, '')}`;
}
export function flagOf(code: string): string {
if (!/^[a-z]{2}$/.test(code)) return '🏳️';
return String.fromCodePoint(...[...code.toUpperCase()].map((c) => 0x1f1a5 + c.charCodeAt(0)));
}
/** ?in= value -> a valid area code (default NL). */
export function parseArea(value: string | null | undefined): string {
const v = (value ?? '').trim().toLowerCase();
if (regionByCode.has(v)) return v;
if (/^[a-z]{2}$/.test(v)) return v;
return DEFAULT_AREA;
}
/** Selector options, grouped: countries then regions. */
export const AREA_OPTIONS = {
countries: COUNTRIES.map((code) => ({ code, label: areaLabel(code) })).sort((a, b) =>
a.code === 'nl' ? -1 : b.code === 'nl' ? 1 : a.label.slice(5).localeCompare(b.label.slice(5))
),
regions: REGIONS.map((r) => ({ code: r.code, label: areaLabel(r.code) }))
};
/** Collection filter (?got=). */
export const GOT = { '': 'all', yes: '✅ collected', no: '⬜ still to go' } as const;
export type Got = keyof typeof GOT;
export const parseGot = (v: string | null): Got => (v === 'yes' || v === 'no' ? v : '');

View File

@@ -0,0 +1,50 @@
// Client-side collection state for the spotting pokedexes: optimistic overrides on top of
// what the server sent, so a toggle updates every card/panel for that entry at once.
import { invalidateAll } from '$app/navigation';
export type SpotTracker = 'cars' | 'planes' | 'trains' | 'wildlife';
const overrides = $state<Record<string, boolean>>({});
const key = (tracker: SpotTracker, id: number) => `${tracker}:${id}`;
export function isSeen(tracker: SpotTracker, id: number, fromServer: boolean): boolean {
const k = key(tracker, id);
return k in overrides ? overrides[k] : fromServer;
}
/** Owner-only POST; reverts the optimistic state when the server refuses. */
export async function saveSeen(
tracker: SpotTracker,
id: number,
seen: boolean,
extra: { seenOn?: string | null; note?: string | null } = {}
): Promise<boolean> {
const k = key(tracker, id);
const before = k in overrides ? overrides[k] : undefined;
overrides[k] = seen;
try {
const res = await fetch(`/trackers/${tracker}/seen`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id, seen, ...extra })
});
if (!res.ok) throw new Error(String(res.status));
await invalidateAll();
delete overrides[k];
return true;
} catch {
if (before === undefined) delete overrides[k];
else overrides[k] = before;
return false;
}
}
type HasSeen = { id: number; seen: boolean };
/** How many of these entries are collected (respecting optimistic toggles). */
export const countSeen = (tracker: SpotTracker, items: HasSeen[]) =>
items.reduce((n, x) => n + (isSeen(tracker, x.id, x.seen) ? 1 : 0), 0);
/** Client-side ?got= filter for group pages that list every entry at once. */
export function filterGot<T extends HasSeen>(tracker: SpotTracker, items: T[], got: string): T[] {
if (got !== 'yes' && got !== 'no') return items;
return items.filter((x) => isSeen(tracker, x.id, x.seen) === (got === 'yes'));
}

View File

@@ -10,6 +10,7 @@
class={['tile', { compact }]}
title={maker.name}
>
{#if maker.seen}<span class="got" title="{maker.seen} collected">✓ {maker.seen}</span>{/if}
<span class="mark"><BrandBadge name={maker.name} logo={maker.logo} size="xl" /></span>
<span class="name">{maker.name}</span>
{#if !compact}
@@ -22,7 +23,20 @@
</a>
<style>
.got {
position: absolute;
top: 0.4rem;
right: 0.4rem;
z-index: 1;
border-radius: 999px;
padding: 0.05rem 0.4rem;
font-size: 0.68rem;
font-weight: 600;
color: #fff;
background: rgb(22 163 74);
}
.tile {
position: relative;
display: flex;
flex-direction: column;
align-items: center;

View File

@@ -42,6 +42,7 @@ export const ERAS = {
} as const;
export type TrainEra = keyof typeof ERAS;
export const SORTS = {
active: 'most active in…',
built: 'most built',
dex: 'dex #',
name: 'name',
@@ -205,6 +206,8 @@ export const fmtM = (m: number) => `${m >= 100 ? Math.round(m) : Math.round(m *
export const fmtT = (t: number) => `${t >= 100 ? Math.round(t) : Math.round(t * 10) / 10} t`;
export type TrainCard = {
id: number;
seen: boolean;
dex: number;
slug: string;
name: string;
@@ -225,6 +228,8 @@ export type TrainCard = {
};
export type MakerTileData = {
/** Entries Stan has collected in this group (optional). */
seen?: number;
slug: string;
name: string;
types: number;

View File

@@ -10,6 +10,7 @@
title="{group.name} · {group.nameNl}"
style:--hue={groupHue(group.slug)}
>
{#if group.seen}<span class="got" title="{group.seen} collected">✓ {group.seen}</span>{/if}
{#if group.photo}
<img
src={photoAt(group.photo, compact ? 'small' : 'medium')}
@@ -31,6 +32,18 @@
</a>
<style>
.got {
position: absolute;
top: 0.4rem;
right: 0.4rem;
z-index: 1;
border-radius: 999px;
padding: 0.05rem 0.4rem;
font-size: 0.68rem;
font-weight: 600;
color: #fff;
background: rgb(22 163 74);
}
.tile {
position: relative;
display: flex;

View File

@@ -9,6 +9,10 @@
WHERE,
type SpeciesCard as Card
} from './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';
@@ -22,7 +26,10 @@
total,
pageSize,
groups = null,
quick = []
quick = [],
areas = [],
collected = null,
collectedLabel = 'species collected'
}: {
base: string;
filters: WildFilters;
@@ -31,11 +38,22 @@
pageSize: number;
groups?: { slug: string; name: string; emoji: string; speciesCount: number }[] | null;
quick?: { label: string; params: Record<string, string> }[];
areas?: string[];
collected?: { seen: number; total: number } | null;
collectedLabel?: string;
} = $props();
const pages = $derived(Math.max(1, Math.ceil(total / pageSize)));
const filtering = $derived(
!!(f.q || (groups && f.group) || f.status || f.where || f.photos || f.sort !== 'nl')
!!(
f.q ||
(groups && f.group) ||
f.status ||
f.where ||
f.photos ||
f.got ||
f.sort !== 'active'
)
);
let form: HTMLFormElement | undefined = $state();
@@ -61,6 +79,17 @@
}
</script>
{#if collected}
<div class="mb-5">
<SpotProgress
seen={collected.seen}
total={collected.total}
label={collectedLabel}
href={withParams({ got: 'no' })}
/>
</div>
{/if}
<form
bind:this={form}
method="GET"
@@ -104,6 +133,10 @@
<NativeSelect name="sort" aria-label="Sort by" value={f.sort} onchange={submit}>
{#each Object.entries(SORTS) as [key, label] (key)}<option value={key}>{label}</option>{/each}
</NativeSelect>
{#if f.sort === 'active'}<AreaSelect value={f.area} available={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>
@@ -126,7 +159,16 @@
{#if species.length}
<ul class="grid">
{#each species as s, i (s.dex)}
<li><SpeciesCard species={s} eager={i < 4} /></li>
<li>
<SpotItem
tracker="wildlife"
id={s.id}
seen={s.seen}
label={s.nameEn ?? s.nameNl ?? s.scientificName}
>
<SpeciesCard species={s} eager={i < 4} />
</SpotItem>
</li>
{/each}
</ul>
{:else}

View File

@@ -53,6 +53,7 @@ export const WHERE = {
export type WildWhere = keyof typeof WHERE;
export const SORTS = {
active: 'most active in…',
nl: 'most seen in NL',
world: 'most seen worldwide',
dex: 'dex #',
@@ -65,6 +66,8 @@ export const SORTS = {
export type WildSort = keyof typeof SORTS;
export type SpeciesCard = {
id: number;
seen: boolean;
dex: number;
slug: string;
groupSlug: string;
@@ -80,6 +83,8 @@ export type SpeciesCard = {
photoCount: number;
};
export type GroupTileData = {
/** Entries Stan has collected in this group (optional). */
seen?: number;
slug: string;
name: string;
nameNl: string;

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 });
};