beeeeg update
Some checks failed
Test and deploy staging / staging (push) Has been cancelled

This commit is contained in:
2026-09-23 04:15:10 +00:00
parent 487eff5bb2
commit d0c7b6548b
135 changed files with 9531 additions and 1777 deletions

View File

@@ -1,59 +1,53 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { deserialize, enhance } from '$app/forms';
import { availabilityValues, type Availability } from '$lib/availability';
import { SvelteSet } from 'svelte/reactivity';
import type { PageProps } from './$types';
import type { Game } from '$lib/server/db/schema';
import ProgressBar from '$lib/components/ProgressBar.svelte';
type Entry = { id: number; availability: Availability; note: string | null };
let { data, form }: PageProps = $props();
const caught = new SvelteSet<number>();
const pending = new SvelteSet<number>();
let saveError = $state('');
$effect(() => {
caught.clear();
for (const id of data.caughtIds) caught.add(id);
});
let { data }: PageProps = $props();
const caught = new SvelteSet(data.caughtIds);
// the full table is ~1000 rows x ~40 games; only render the rows inside
// the scroll viewport (plus some overscan) to keep the DOM small
const ROW_HEIGHT = 37; // keep in sync with h-[37px] on the rows
const OVERSCAN = 10;
let scrollEl = $state<HTMLDivElement>();
let scrollTop = $state(0);
let viewportHeight = $state(600);
const startIndex = $derived(Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN));
const endIndex = $derived(
Math.min(data.pokemon.length, Math.ceil((scrollTop + viewportHeight) / ROW_HEIGHT) + OVERSCAN)
);
const visiblePokemon = $derived(data.pokemon.slice(startIndex, endIndex));
// optimistic toggle without a <form> per cell — one shared handler instead
// of ~10k use:enhance initializations
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 = false;
let failed: boolean;
try {
const response = await fetch('?/togglePokemonCaught', { method: 'POST', body });
const result = deserialize(await response.text());
failed = result.type === 'failure' || result.type === 'error';
failed = result.type !== 'success';
} catch {
failed = true;
}
pending.delete(entryId);
if (failed) {
// server rejected the toggle, roll back the optimistic update
saveError = 'Could not save your catch. Please try again, or log in again.';
if (checked) caught.delete(entryId);
else caught.add(entryId);
}
}
// pokemonId -> gameId -> entry, for quick cell lookup
const entryMap = $derived(
new Map(
data.pokemon.map((pkmn) => [
pkmn.id,
data.pokemon.map((pokemon) => [
pokemon.id,
new Map(
pkmn.entries.map(([id, gameId, code, note]) => [
pokemon.entries.map(([id, gameId, code, note]) => [
gameId,
{ id, availability: availabilityValues[code], note: note ?? null } satisfies Entry
])
@@ -61,17 +55,21 @@
])
)
);
// empty selection = user hasn't set rules yet, show everything
const selectedGameIds = $derived(new Set(data.selectedGameIds));
const visibleGames = $derived(
selectedGameIds.size > 0 ? data.games.filter((g) => selectedGameIds.has(g.id)) : data.games
data.gamesConfigured ? data.games.filter((game) => selectedGameIds.has(game.id)) : data.games
);
const visibleGameIds = $derived(new Set(visibleGames.map((g) => g.id)));
const visibleGameIds = $derived(new Set(visibleGames.map((game) => game.id)));
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 catchableEntries = $derived(
data.pokemon
.flatMap((pkmn) => pkmn.entries)
.flatMap((pokemon) => pokemon.entries)
.filter(
([, gameId, code]) => availabilityValues[code] === 'catchable' && visibleGameIds.has(gameId)
)
@@ -80,194 +78,227 @@
const caughtCount = $derived(
catchableEntries.reduce((n, [id]) => n + (caught.has(id) ? 1 : 0), 0)
);
const percentage = $derived(total > 0 ? ((caughtCount / total) * 100).toFixed(2) : '0.00');
const percentage = $derived(total > 0 ? ((caughtCount / total) * 100).toFixed(1) : '0.0');
let search = $state('');
let pageNumber = $state(1);
const pageSize = 25;
const filteredPokemon = $derived(
data.pokemon.filter((pokemon) =>
`${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, (g) => g.generation)].sort(([a], [b]) => a - b)
[...Map.groupBy(data.games, (game) => game.generation)].sort(([a], [b]) => a - b)
);
// rules modal
let rulesDialog: HTMLDialogElement;
let draft = $state(new SvelteSet<number>());
const draft = new SvelteSet<number>();
function openRules() {
draft = new SvelteSet(selectedGameIds.size > 0 ? selectedGameIds : data.games.map((g) => g.id));
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 (!data.user) return 'Log in to track your progress';
if (!entry) return `Not available in ${game.name}`;
if (!entry) return `No catalog record for ${game.name}; availability is not confirmed`;
const reason = {
catchable: null,
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'
event_only: 'Only obtainable through an event distribution',
unknown: 'Availability has not been confirmed'
}[entry.availability];
if (reason === null) return null;
return entry.note ? `${reason} (${entry.note})` : reason;
}
const typeColors: Record<string, string> = {
Normal: '#A8A77A',
Fire: '#EE8130',
Water: '#6390F0',
Electric: '#F7D02C',
Grass: '#7AC74C',
Ice: '#96D9D6',
Fighting: '#C22E28',
Poison: '#A33EA1',
Ground: '#E2BF65',
Flying: '#A98FF3',
Psychic: '#F95587',
Bug: '#A6B91A',
Rock: '#B6A136',
Ghost: '#735797',
Dragon: '#6F35FC',
Dark: '#705746',
Steel: '#B7B7CE',
Fairy: '#D685AD'
};
</script>
<svelte:head>
<title>catchemall</title>
</svelte:head>
<div class="flex flex-col">
<h1 class="py-4 text-center text-2xl font-bold">Gotta catch 'em all!</h1>
</div>
<div class="flex justify-center">
<p class="max-w-200 py-6 text-center">
Below are all {data.pokemon.length} Pokemon across {data.games.length} games. Play through all generations
and catch all catchable pokemon in each. Use the "Set rules" button to pick which games you want to
complete.
</p>
</div>
{#if !data.user}
<p class="pb-4 text-center text-sm text-gray-500">
<a href="/login" class="font-bold text-blue-500 underline">Log in</a> to save your progress.
</p>
{/if}
<div class="flex items-center justify-center gap-4 py-4">
<button
class="cursor-pointer rounded-lg bg-blue-400 px-4 py-2 font-bold text-white hover:bg-blue-500"
onclick={openRules}>Set rules</button
>
<p>{caughtCount}/{total} entries caught ({percentage} %)</p>
<progress value={total > 0 ? caughtCount / total : 0}></progress>
</div>
<div
bind:this={scrollEl}
bind:clientHeight={viewportHeight}
onscroll={() => (scrollTop = scrollEl?.scrollTop ?? 0)}
class="max-h-[75vh] overflow-auto rounded-xl border border-gray-300"
>
<table class="min-w-full table-fixed border-separate border-spacing-0 text-sm">
<thead>
<tr>
<th
class="sticky top-0 left-0 z-30 w-90 border-r border-b border-gray-300 bg-white px-2 text-left"
>Pokemon</th
>
{#each visibleGames as game (game.id)}
<th
class="sticky top-0 z-20 w-9 border-b border-gray-300 bg-white px-1 pt-2 pb-1 align-bottom"
>
<span class="mx-auto rotate-180 [writing-mode:vertical-rl]">{game.name}</span>
</th>
{/each}
</tr>
</thead>
<tbody>
{#if startIndex > 0}
<tr aria-hidden="true">
<td colspan={visibleGames.length + 1} style="height: {startIndex * ROW_HEIGHT}px"></td>
</tr>
{/if}
{#each visiblePokemon as pokemon (pokemon.id)}
<tr class="h-[37px] hover:bg-blue-50">
<th
class="sticky left-0 z-10 border-r border-b border-gray-200 bg-white px-2 py-0.5 text-left font-normal whitespace-nowrap"
>
<div class="flex items-center gap-2">
<img
src="https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/{pokemon.number}.png"
alt=""
loading="lazy"
class="h-8 w-8"
/>
<span class="min-w-10 font-bold">{pokemon.number}</span>
<span class="min-w-30">{pokemon.name}</span>
<span
class="rounded-lg px-2 py-0.5 text-xs"
style:background-color={typeColors[pokemon.type1]}>{pokemon.type1}</span
>
{#if pokemon.type2}
<span
class="rounded-lg px-2 py-0.5 text-xs"
style:background-color={typeColors[pokemon.type2]}>{pokemon.type2}</span
>
{/if}
</div>
</th>
{#each visibleGames as game (game.id)}
{@const entry = entryMap.get(pokemon.id)?.get(game.id)}
{@const reason = tooltip(entry, game)}
<td class="border-b border-gray-200 text-center">
{#if entry && reason === null}
<input
type="checkbox"
class="cursor-pointer"
checked={caught.has(entry.id)}
onchange={(e) => toggleCaught(entry.id, e.currentTarget.checked)}
/>
{:else}
<span title={reason} class="cursor-help">
<input type="checkbox" disabled checked={entry ? caught.has(entry.id) : false} />
</span>
{/if}
</td>
{/each}
</tr>
{/each}
{#if endIndex < data.pokemon.length}
<tr aria-hidden="true">
<td
colspan={visibleGames.length + 1}
style="height: {(data.pokemon.length - endIndex) * ROW_HEIGHT}px"
></td>
</tr>
{/if}
</tbody>
</table>
</div>
<dialog bind:this={rulesDialog} class="m-auto w-full max-w-2xl rounded-xl p-6 backdrop:bg-black/50">
<h2 class="pb-2 text-xl font-bold">Set rules</h2>
<p class="pb-4 text-sm text-gray-600">
Pick the games you want to catch 'em all in. Only those show up in the table and count towards
your progress.
</p>
<div class="flex gap-2 pb-4">
<div class="mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-14">
<div class="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
type="button"
class="cursor-pointer rounded-lg bg-gray-200 px-3 py-1 text-sm hover:bg-gray-300"
onclick={() => (draft = new SvelteSet(data.games.map((g) => g.id)))}>All games</button
>
<button
type="button"
class="cursor-pointer rounded-lg bg-gray-200 px-3 py-1 text-sm hover:bg-gray-300"
onclick={() => (draft = new SvelteSet())}>None</button
onclick={openRules}
class="rounded border border-[var(--site-border)] px-4 py-2 hover:bg-[var(--site-hover)]"
>set rules</button
>
</div>
{#if !data.user}<p class="mt-6">
<a
href={resolve('/login?returnTo=/projects/catch-em-all')}
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">{percentage}%</p>
</div>
<div class="mt-3">
<ProgressBar value={caughtCount} max={total} label="Overall catch progress" />
</div>
<p class="mt-2 text-base text-[var(--site-muted)]">{caughtCount} of {total} entries caught</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 block w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-3"
/>
</label>
<label class="block"
>game
<select
bind:value={viewGameId}
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>
{#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
>
<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">
<div class="flex min-w-0 items-center gap-3">
<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}</span
>
</div>
</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={!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
type="button"
disabled={pageNumber === 1}
onclick={() => (pageNumber -= 1)}
class="disabled:opacity-40">previous</button
>
<span class="text-[var(--site-muted)]">{pageNumber}/{pageCount}</span>
<button
type="button"
disabled={pageNumber === pageCount}
onclick={() => (pageNumber += 1)}
class="disabled:opacity-40">next</button
>
</div>
</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}
<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">
checkboxes represent confirmed encounters from PokéAPI. “?” means the Pokémon appears in a
game catalog but its acquisition method is unconfirmed. “—” means there is no source record.
Neither proves it is unobtainable.
</p>
</details>
</div>
<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);
}}
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"
@@ -278,51 +309,45 @@
};
}}
>
<div class="grid max-h-96 grid-cols-2 gap-x-6 overflow-y-auto sm:grid-cols-3">
<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 class="pb-3">
<legend class="flex items-center gap-2 pb-1 font-bold">
<input
type="checkbox"
checked={games.every((g) => draft.has(g.id))}
onchange={(e) => {
for (const g of games) {
if (e.currentTarget.checked) draft.add(g.id);
else draft.delete(g.id);
}
}}
/>
Gen {generation}
</legend>
{#each games as game (game.id)}
<label class="flex items-center gap-2 py-0.5 pl-4 text-sm">
<input
<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={(e) => {
if (e.currentTarget.checked) draft.add(game.id);
onchange={(event) => {
if (event.currentTarget.checked) draft.add(game.id);
else draft.delete(game.id);
}}
/>
{game.name}
</label>
{/each}
/>{game.name}</label
>{/each}
</fieldset>
{/each}
</div>
<div class="flex justify-end gap-2 pt-4">
<div class="mt-6 flex justify-end gap-5">
<button type="button" onclick={() => rulesDialog.close()}>cancel</button>
<button
type="button"
class="cursor-pointer rounded-lg bg-gray-200 px-4 py-2 font-bold hover:bg-gray-300"
onclick={() => rulesDialog.close()}>Cancel</button
>
<button
class="cursor-pointer rounded-lg bg-blue-400 px-4 py-2 font-bold text-white hover:bg-blue-500"
disabled={!data.user}
title={data.user ? undefined : 'Log in to save rules'}>Save</button
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>
</form>