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
Some checks failed
Deploy production / deploy (push) Has been cancelled
This commit is contained in:
31
src/lib/spotting/AreaSelect.svelte
Normal file
31
src/lib/spotting/AreaSelect.svelte
Normal 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>
|
||||
154
src/lib/spotting/SeenPanel.svelte
Normal file
154
src/lib/spotting/SeenPanel.svelte
Normal 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>
|
||||
88
src/lib/spotting/SpotItem.svelte
Normal file
88
src/lib/spotting/SpotItem.svelte
Normal 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>
|
||||
98
src/lib/spotting/SpotProgress.svelte
Normal file
98
src/lib/spotting/SpotProgress.svelte
Normal 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
277
src/lib/spotting/areas.ts
Normal 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 : '');
|
||||
50
src/lib/spotting/seen.svelte.ts
Normal file
50
src/lib/spotting/seen.svelte.ts
Normal 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'));
|
||||
}
|
||||
Reference in New Issue
Block a user