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

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