Expand website and game trackers
Some checks failed
Test and deploy staging / staging (push) Failing after 5m29s

This commit is contained in:
2026-09-25 22:43:04 +00:00
parent f6b34150d4
commit c2ba8febb4
4076 changed files with 97237 additions and 1453 deletions

View File

@@ -1,474 +1,192 @@
<script lang="ts">
import { onMount } from 'svelte';
import { resolve } from '$app/paths';
import { deserialize, enhance } from '$app/forms';
import { invalidateAll } from '$app/navigation';
import {
availabilityLabels,
availabilityValues,
obtainableAvailability,
type Availability
} from '$lib/availability';
import { SvelteSet } from 'svelte/reactivity';
import type { PageProps } from './$types';
import ProgressBar from '$lib/components/ProgressBar.svelte';
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import PokemonDetails from '$lib/catch-em-all/PokemonDetails.svelte';
import { summarizeProgress } from '$lib/catch-em-all/progress';
type Entry = { id: number; availability: Availability; note: string | null };
type Game = { id: number; name: string };
let { data, form }: PageProps = $props();
// rebuilt whenever the page data changes, so the server render already shows progress
const caught = $derived(new SvelteSet(data.caughtIds));
const pending = new SvelteSet<number>();
let saveError = $state('');
// checkboxes stay disabled until hydration, so early clicks can't silently go unsaved
let hydrated = $state(false);
onMount(() => (hydrated = true));
async function toggleCaught(entryId: number, checked: boolean) {
if (pending.has(entryId)) return;
pending.add(entryId);
saveError = '';
if (checked) caught.add(entryId);
else caught.delete(entryId);
const body = new FormData();
body.set('entryId', String(entryId));
if (checked) body.set('caught', 'on');
let failed: boolean;
try {
const response = await fetch('?/togglePokemonCaught', { method: 'POST', body });
const result = deserialize(await response.text());
failed = result.type !== 'success';
} catch {
failed = true;
}
pending.delete(entryId);
if (failed) {
saveError = 'Could not save your catch. Please try again, or log in again.';
if (checked) caught.delete(entryId);
else caught.add(entryId);
}
}
const entryMap = $derived(
new Map(
data.pokemon.map((pokemon) => [
pokemon.id,
new Map(
pokemon.entries.map(([id, gameId, code, note]) => [
gameId,
{ id, availability: availabilityValues[code], note: note ?? null } satisfies Entry
])
)
])
onMount(() => {
hydrated = true;
});
import { resolve } from '$app/paths';
import { goto } from '$app/navigation';
import { enhance } from '$app/forms';
import { SvelteSet } from 'svelte/reactivity';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
import { chronologicalPokemon } from '$lib/game-tracker/game-art';
import GameLogo from '$lib/game-tracker/GameLogo.svelte';
import PokemonJournal from '$lib/game-tracker/PokemonJournal.svelte';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
let rulesOpen = $state(false);
let stripWidth = $state(1000);
const coverRows = $derived(visibleGames.length > Math.floor((stripWidth + 4) / 68) ? 2 : 1);
let saving = $state(false);
const draft = new SvelteSet<number>();
const visibleGames = $derived(
chronologicalPokemon(
data.gamesConfigured
? data.games.filter((g) => data.selectedGameIds.includes(g.id))
: data.games
)
);
const selectedGameIds = $derived(new Set(data.selectedGameIds));
const visibleGames = $derived(
data.gamesConfigured ? data.games.filter((game) => selectedGameIds.has(game.id)) : data.games
);
let viewGameId = $state<number | null>(null);
$effect(() => {
if (!visibleGames.some((game) => game.id === viewGameId))
viewGameId = visibleGames[0]?.id ?? null;
});
const viewGame = $derived(visibleGames.find((game) => game.id === viewGameId));
const visibleGameIds = $derived(new Set(visibleGames.map((game) => game.id)));
const progress = $derived(summarizeProgress(data.pokemon, visibleGameIds, caught));
const progressBars = $derived([
{
label: 'seen at least once',
value: progress.seenPokemon,
total: progress.catchablePokemon,
unit: 'Pokémon'
},
{
label: 'caught in every game it’s in',
value: progress.completedPokemon,
total: progress.catchablePokemon,
unit: 'Pokémon'
},
{
label: 'games completed',
value: progress.completedGames,
total: progress.catchableGames,
unit: 'games'
}
]);
function percent(value: number, total: number) {
return total > 0 ? ((value / total) * 100).toFixed(1) : '0.0';
}
let search = $state('');
let showUnobtainable = $state(false);
let pageNumber = $state(1);
const pageSize = 25;
const filteredPokemon = $derived(
data.pokemon.filter((pokemon) => {
const availability =
viewGameId === null ? undefined : entryMap.get(pokemon.id)?.get(viewGameId)?.availability;
if (
!showUnobtainable &&
viewGameId !== null &&
!(availability && obtainableAvailability.has(availability))
)
return false;
return `${pokemon.number} ${pokemon.name}`
.toLowerCase()
.includes(search.trim().toLowerCase());
})
);
const pageCount = $derived(Math.max(1, Math.ceil(filteredPokemon.length / pageSize)));
$effect(() => {
if (pageNumber > pageCount) pageNumber = pageCount;
});
const visiblePokemon = $derived(
filteredPokemon.slice((pageNumber - 1) * pageSize, pageNumber * pageSize)
);
const gamesByGeneration = $derived(
[...Map.groupBy(data.games, (game) => game.generation)].sort(([a], [b]) => a - b)
);
let rulesDialog: HTMLDialogElement;
const draft = new SvelteSet<number>();
const generations = $derived([...Map.groupBy(data.games, (g) => g.generation)]);
function openRules() {
draft.clear();
for (const id of data.gamesConfigured ? selectedGameIds : data.games.map((game) => game.id))
draft.add(id);
rulesDialog.showModal();
}
function tooltip(entry: Entry | undefined, game: Game): string | null {
if (!entry) return `No catalog record for ${game.name}; availability is not confirmed`;
if (obtainableAvailability.has(entry.availability)) return null;
const reason = {
trade_only: 'Only obtainable by trading in this game',
transfer_only: 'Only obtainable by transferring from another game',
event_only: 'Only obtainable through an event distribution',
unknown: 'Availability has not been confirmed'
}[entry.availability as 'trade_only' | 'transfer_only' | 'event_only' | 'unknown'];
return entry.note ? `${reason} (${entry.note})` : reason;
}
let detailsDialog: HTMLDialogElement;
let detailNumber = $state<number | null>(null);
function openDetails(number: number) {
detailNumber = number;
detailsDialog.showModal();
for (const game of data.games)
if (!data.gamesConfigured || data.selectedGameIds.includes(game.id)) draft.add(game.id);
rulesOpen = true;
}
</script>
<div class="mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-14">
<a
href={resolve('/projects/game-tracker')}
class="text-[var(--site-muted)] hover:text-[var(--site-text)]">← game tracker</a
<div class="mx-auto max-w-[1600px] px-3 py-4 sm:px-5">
<div
class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-2 lg:grid-cols-[auto_minmax(0,1fr)_auto]"
>
<div class="mt-3 flex flex-wrap items-end justify-between gap-5">
<div>
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">catchemall</h1>
<p class="mt-2 text-[var(--site-muted)]">
{data.pokemon.length} Pokémon · {data.games.length} games
</p>
</div>
<Button variant="outline" type="button" onclick={openRules}>set rules</Button>
</div>
{#if !data.user}<p class="mt-6">
<a
href={resolve('/login?returnTo=/projects/game-tracker/pokemon')}
class="underline underline-offset-4">sign in</a
> to save your progress.
</p>{/if}
{#if saveError}<p role="alert" class="mt-5 text-red-500">{saveError}</p>{/if}
{#if !data.pokemon.length}<p class="mt-8 text-[var(--site-muted)]">
the Pokémon catalog has not been loaded yet.
</p>{/if}
<div class="mt-8 border-y border-[var(--site-border)] py-6">
<div class="flex flex-wrap items-baseline justify-between gap-2">
<p class="font-medium">overall progress</p>
<p class="text-2xl font-semibold tabular-nums">
{percent(progress.caughtEntries, progress.catchableEntries)}%
</p>
</div>
<div class="mt-3">
<ProgressBar
value={progress.caughtEntries}
max={progress.catchableEntries}
label="Overall catch progress"
/>
</div>
<p class="mt-2 text-base text-[var(--site-muted)]">
{progress.caughtEntries} of {progress.catchableEntries} obtainable entries caught
</p>
<div class="mt-8 grid gap-x-10 gap-y-7 lg:grid-cols-3">
{#each progressBars as item (item.label)}
<div class="min-w-0">
<div class="flex flex-wrap items-baseline justify-between gap-2">
<p class="font-medium">{item.label}</p>
<p class="text-xl font-semibold tabular-nums">{percent(item.value, item.total)}%</p>
</div>
<div class="mt-3">
<ProgressBar value={item.value} max={item.total} label={item.label} small />
</div>
<p class="mt-2 text-base text-[var(--site-muted)]">
{item.value} of {item.total}
{item.unit}
</p>
</div>
{/each}
</div>
<p class="mt-6 text-base text-[var(--site-muted)]">
counts use your selected games. Pokémon you can evolve or breed there count as obtainable.
</p>
</div>
<div class="mt-8 grid gap-4 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<label class="block"
>search Pokémon
<Input
type="search"
bind:value={search}
oninput={() => (pageNumber = 1)}
placeholder="name or number"
class="mt-2 h-11"
/>
</label>
<label class="block"
>game
<select
bind:value={viewGameId}
onchange={() => (pageNumber = 1)}
disabled={!visibleGames.length}
class="mt-2 block w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-3"
>
{#each visibleGames as game (game.id)}<option value={game.id}>{game.name}</option>{/each}
</select>
</label>
</div>
<div class="mt-5 flex flex-wrap items-center justify-between gap-4">
<label class="flex cursor-pointer items-center gap-3">
<input
type="checkbox"
checked={showUnobtainable}
onchange={(event) => {
showUnobtainable = event.currentTarget.checked;
pageNumber = 1;
}}
class="h-5 w-5 accent-[var(--site-text)]"
/>
show Pokémon you can’t get in this game
</label>
<a
href={resolve('/projects/game-tracker/pokemon/locations')}
class="underline underline-offset-4">browse locations</a
>
</div>
{#if viewGame}
<div class="mt-8">
<table class="w-full table-fixed border-collapse">
<colgroup><col /><col class="w-24 sm:w-36" /></colgroup>
<thead
><tr class="border-b border-[var(--site-border)] text-left text-[var(--site-muted)]"
><th scope="col" class="py-3 font-medium">Pokémon</th><th
scope="col"
class="py-3 text-center font-medium">caught</th
></tr
></thead
<div class="flex items-center gap-3">
{#if data.activeGame}<GameLogo
slug={`pokemon/${data.activeGame.slug}`}
name={data.activeGame.name}
class="h-12 w-12"
/>{/if}
<div>
<a
href={resolve('/projects/game-tracker')}
class="text-xs text-muted-foreground hover:underline">← Game tracker</a
>
<tbody>
{#each visiblePokemon as pokemon (pokemon.id)}
{@const entry = entryMap.get(pokemon.id)?.get(viewGame.id)}
{@const reason = tooltip(entry, viewGame)}
<tr class="border-b border-[var(--site-border)]">
<th scope="row" class="py-2 text-left font-normal">
<button
type="button"
onclick={() => openDetails(pokemon.number)}
class="flex w-full min-w-0 items-center gap-3 text-left hover:text-[var(--site-muted)]"
>
<img
src="https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/{pokemon.number}.png"
alt=""
loading="lazy"
width="40"
height="40"
class="h-10 w-10 shrink-0"
/>
<span class="min-w-0"
><span class="mr-2 text-[var(--site-muted)]">#{pokemon.number}</span
>{pokemon.name}
{#if entry && reason === null}<span
class="block truncate text-sm text-[var(--site-muted)]"
>{availabilityLabels[entry.availability]}{entry.note
? ` · ${entry.note}`
: ''}</span
>{/if}</span
>
</button>
</th>
<td class="py-2 text-center">
{#if entry && reason === null}
<input
type="checkbox"
class="h-5 w-5 cursor-pointer accent-[var(--site-text)]"
checked={caught.has(entry.id)}
disabled={!hydrated || !data.user || pending.has(entry.id)}
aria-label={`Caught ${pokemon.name} in ${viewGame.name}`}
title={data.user ? (entry.note ?? undefined) : 'Sign in to save your progress'}
onchange={(event) => toggleCaught(entry.id, event.currentTarget.checked)}
/>
{:else}
<span
title={reason ?? undefined}
aria-label={`${pokemon.name} in ${viewGame.name}: ${reason}`}
class="text-[var(--site-muted)]"
>{!entry ? '—' : entry.availability === 'unknown' ? '?' : '·'}</span
>
{/if}
</td>
</tr>
{:else}
<tr><td colspan="2" class="py-8 text-[var(--site-muted)]">no Pokémon found</td></tr>
{/each}
</tbody>
</table>
<div class="mt-5 flex flex-wrap items-center justify-between gap-4">
<p class="text-[var(--site-muted)]">
{filteredPokemon.length ? (pageNumber - 1) * pageSize + 1 : 0}–{Math.min(
pageNumber * pageSize,
filteredPokemon.length
)} of {filteredPokemon.length}
</p>
<div class="flex items-center gap-4">
<Button
variant="outline"
size="sm"
type="button"
disabled={pageNumber === 1}
onclick={() => (pageNumber -= 1)}>previous</Button
<h1 class="text-xl font-semibold">
Pokémon journal <span class="text-sm font-normal text-muted-foreground"
>· {data.activeGame?.name ?? 'Choose a game'}</span
>
<span class="text-[var(--site-muted)]">{pageNumber}/{pageCount}</span>
<Button
variant="outline"
size="sm"
type="button"
disabled={pageNumber === pageCount}
onclick={() => (pageNumber += 1)}>next</Button
>
</div>
</h1>
</div>
</div>
{:else if data.pokemon.length}
<p class="mt-8 text-[var(--site-muted)]">no games selected. use “set rules” to choose one.</p>
{/if}
<div
class={`game-covers order-3 col-span-2 overflow-x-auto pb-1 ${visibleGames.length > 30 ? 'lg:col-span-3' : 'lg:order-none lg:col-span-1'}`}
bind:clientWidth={stripWidth}
role="group"
aria-label="Choose Pokémon game"
>
<div
class="grid gap-1"
style:grid-template-columns={`repeat(${Math.max(1, Math.ceil(visibleGames.length / coverRows))}, minmax(40px, 1fr))`}
style:max-width={`${Math.ceil(visibleGames.length / coverRows) * 64}px`}
>
{#each visibleGames as game (game.id)}
<Button
variant="ghost"
disabled={!hydrated}
aria-pressed={data.activeGame?.id === game.id}
aria-label={`Play ${game.name}`}
title={game.name}
class={`game-cover-button h-12 min-w-0 rounded-md border p-1 ${data.activeGame?.id === game.id ? 'border-amber-500 bg-amber-500/15 ring-1 ring-amber-500' : 'border-transparent opacity-65 hover:opacity-100'}`}
onclick={() =>
goto(resolve(`/projects/game-tracker/pokemon?game=${game.slug ?? game.id}`), {
noScroll: true,
keepFocus: true
})}
>
<GameLogo slug={`pokemon/${game.slug}`} name={game.name} class="h-10 w-full" />
</Button>
{/each}
</div>
</div>
<Button
class="justify-self-end lg:col-start-3"
variant="ghost"
size="sm"
disabled={!hydrated}
onclick={openRules}>set rules</Button
>
</div>
<details class="mt-10 border-t border-[var(--site-border)] pt-5 text-[var(--site-muted)]">
<summary class="cursor-pointer">about availability</summary>
<p class="mt-3 max-w-3xl">
the catalog is owned by this site and built from a pinned PokéAPI snapshot. Checkboxes appear
for Pokémon you can catch (wild, gift, static or in-game trade), evolve from something you can
get in that game, or breed. “·” marks event-only mythicals, “?” means the Pokémon is in the
game but no route is confirmed, and “—” means there is no record. Neither proves it is
unobtainable. Newer games (Brilliant Diamond and later) have no encounter data yet.
</p>
</details>
{#if !data.user}<p class="mt-4 text-sm">
<a href={resolve('/login?returnTo=/projects/game-tracker/pokemon')} class="underline"
>Sign in</a
> to save catches, teams and adventures.
</p>{/if}
{#if form?.message && !rulesOpen}<p role="alert" class="mt-4 text-destructive">
{form.message}
</p>{/if}
{#if data.activeGame}{#key data.activeGame.id}<PokemonJournal {data} />{/key}{:else}<p
class="mt-8 text-muted-foreground"
>
No games selected. Use “set rules” to choose your next adventure.
</p>{/if}
<p class="mt-4 text-[10px] leading-relaxed text-muted-foreground">
Species, regional Pokédexes, moves and encounters from <a
href="https://pokeapi.co/"
class="underline">PokéAPI</a
>. Encounter coverage varies by game; missing records do not prove that a Pokémon is
unavailable. Stats and move battle values describe the catalog’s default forms and current
snapshot. Maps identify their source and coverage.
</p>
</div>
<dialog
bind:this={detailsDialog}
onclose={() => (detailNumber = null)}
class="m-auto max-h-[90vh] w-[calc(100%-2rem)] max-w-2xl overflow-y-auto rounded-xl border border-[var(--site-border)] bg-[var(--site-surface)] p-5 text-[var(--site-text)] backdrop:bg-black/70 sm:p-6"
>
<div class="flex justify-end">
<button type="button" onclick={() => detailsDialog.close()} class="text-[var(--site-muted)]"
>close</button
<Dialog.Root bind:open={rulesOpen}>
<Dialog.Content class="max-h-[90dvh] overflow-y-auto sm:max-w-3xl">
<Dialog.Header
><Dialog.Title>Set rules</Dialog.Title><Dialog.Description
>Choose the games in your collection. Your saved catches stay with each game.</Dialog.Description
></Dialog.Header
>
</div>
{#if detailNumber !== null}
<PokemonDetails
number={detailNumber}
gameId={viewGameId}
isAdmin={data.isAdmin}
onsaved={() => invalidateAll()}
/>
{/if}
</dialog>
<dialog
bind:this={rulesDialog}
class="m-auto w-[calc(100%-2rem)] max-w-2xl rounded-xl border border-[var(--site-border)] bg-[var(--site-surface)] p-5 text-[var(--site-text)] backdrop:bg-black/70 sm:p-6"
>
<h2 class="text-2xl font-semibold">set rules</h2>
<p class="mt-2 text-[var(--site-muted)]">choose the games that count toward your progress.</p>
{#if form?.message}<p role="alert" class="mt-3 text-red-500">{form.message}</p>{/if}
<div class="mt-5 flex gap-5">
<button
type="button"
onclick={() => {
draft.clear();
for (const game of data.games) draft.add(game.id);
{#if form?.message}<p role="alert" class="text-destructive">{form.message}</p>{/if}
<div class="flex gap-2">
<Button
variant="outline"
size="sm"
onclick={() => {
for (const game of data.games) draft.add(game.id);
}}>Select all</Button
><Button variant="ghost" size="sm" onclick={() => draft.clear()}>Clear selection</Button>
</div>
<form
method="POST"
action="?/setGames"
use:enhance={() => {
saving = true;
return async ({ result, update }) => {
await update();
saving = false;
if (result.type === 'success') rulesOpen = false;
};
}}
class="underline underline-offset-4">all</button
>
<button type="button" onclick={() => draft.clear()} class="underline underline-offset-4"
>none</button
>
</div>
<form
method="POST"
action="?/setGames"
use:enhance={() => {
return async ({ result, update }) => {
await update();
if (result.type === 'success') rulesDialog.close();
};
}}
>
<div class="mt-5 grid max-h-[50vh] gap-5 overflow-y-auto pr-2 sm:grid-cols-2">
{#each gamesByGeneration as [generation, games] (generation)}
<fieldset>
<legend class="mb-2 font-semibold"
><label class="flex items-center gap-2"
><input
type="checkbox"
checked={games.every((game) => draft.has(game.id))}
onchange={(event) => {
for (const game of games) {
if (event.currentTarget.checked) draft.add(game.id);
else draft.delete(game.id);
}
}}
/>generation {generation}</label
></legend
>
{#each games as game (game.id)}<label class="flex items-center gap-2 py-1 pl-5"
><input
type="checkbox"
name="gameIds"
value={game.id}
checked={draft.has(game.id)}
onchange={(event) => {
if (event.currentTarget.checked) draft.add(game.id);
else draft.delete(game.id);
}}
/>{game.name}</label
>{/each}
</fieldset>
{/each}
</div>
<div class="mt-6 flex justify-end gap-5">
<button type="button" onclick={() => rulesDialog.close()}>cancel</button>
<button
disabled={!data.user}
title={data.user ? undefined : 'Sign in to save rules'}
class="rounded bg-[var(--site-text)] px-4 py-2 font-semibold text-[var(--site-bg)] disabled:opacity-40"
>save</button
<div class="grid max-h-[55dvh] gap-6 overflow-auto pr-2 sm:grid-cols-2">
{#each generations as [generation, games] (generation)}
<fieldset>
<legend class="mb-3"
><label class="flex items-center gap-2 font-semibold"
><Checkbox
checked={games.every((g) => draft.has(g.id))}
indeterminate={games.some((g) => draft.has(g.id)) &&
!games.every((g) => draft.has(g.id))}
onCheckedChange={(checked) => {
for (const g of games)
if (checked) draft.add(g.id);
else draft.delete(g.id);
}}
/>Generation {generation}</label
></legend
>
<div class="space-y-2">
{#each games as g (g.id)}<label class="flex items-center gap-3 rounded-lg border p-2"
><Checkbox
name="gameIds"
value={String(g.id)}
checked={draft.has(g.id)}
onCheckedChange={(checked) => (checked ? draft.add(g.id) : draft.delete(g.id))}
/><GameLogo slug={`pokemon/${g.slug}`} name={g.name} class="h-8 w-16" /><span
class="text-sm">{g.name}</span
></label
>{/each}
</div>
</fieldset>{/each}
</div>
<Dialog.Footer class="mt-6"
><Button variant="outline" onclick={() => (rulesOpen = false)}>Cancel</Button><Button
type="submit"
disabled={!data.user || saving}>{saving ? 'Saving…' : 'Save rules'}</Button
></Dialog.Footer
>
</div>
</form>
</dialog>
</form>
</Dialog.Content>
</Dialog.Root>