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

@@ -0,0 +1,183 @@
<script lang="ts">
import { pokemonImage } from '$lib/game-tracker/game-art';
import { enhance } from '$app/forms';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Textarea } from '$lib/components/ui/textarea/index.js';
import { NativeSelect } from '$lib/components/ui/native-select/index.js';
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
import { stages, obtainedMethods, type Stage } from './journal';
import type { PokemonCatch } from '$lib/server/db/schema/tracker-journal';
let {
open = $bindable(false),
species,
gameId,
record = null,
stage = 'story',
location = '',
signedIn,
ondelete,
ondetails
}: {
open?: boolean;
species: { id: number; number: number; name: string };
gameId: number;
record?: PokemonCatch | null;
stage?: Stage;
location?: string;
signedIn: boolean;
ondelete?: () => void;
ondetails?: () => void;
} = $props();
let message = $state('');
let saving = $state(false);
</script>
<Dialog.Root bind:open>
<Dialog.Content class="max-h-[90dvh] overflow-y-auto sm:max-w-2xl">
<Dialog.Header
><Dialog.Title>{record ? 'Meet' : 'Register'} {record?.nickname || species.name}</Dialog.Title
><Dialog.Description
>#{species.number} · {species.name}. Keep this Pokémon’s story, moves and party position
together.</Dialog.Description
></Dialog.Header
>
{#if ondetails}<Button variant="link" class="justify-self-start px-0" onclick={ondetails}
>All catches, moves and species research →</Button
>{/if}
{#if message}<p role="alert" class="text-destructive">{message}</p>{/if}
<form
method="POST"
action="?/saveCatch"
class="space-y-5"
use:enhance={() => {
saving = true;
message = '';
return async ({ result, update }) => {
await update({ reset: false });
saving = false;
if (result.type === 'success') open = false;
else
message =
result.type === 'failure'
? String(result.data?.message ?? 'Could not save.')
: 'Could not save. Try again.';
};
}}
>
<input type="hidden" name="gameId" value={gameId} /><input
type="hidden"
name="pokemonId"
value={species.id}
/>{#if record}<input type="hidden" name="id" value={record.id} />{/if}
<div class="flex items-center gap-4 rounded-xl bg-muted/40 p-3">
<img
src={pokemonImage(species.number, record?.shiny ? 'shiny' : 'sprite')}
alt={species.name}
width="80"
height="80"
/>
<div class="grid flex-1 grid-cols-[1fr_5rem] gap-3">
<label
>Nickname<Input
name="nickname"
value={record?.nickname ?? ''}
maxlength={50}
placeholder={species.name}
/></label
><label
>Level<Input
type="number"
name="level"
min={1}
max={100}
required
value={record?.level ?? 5}
/></label
>
</div>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<label
>Party position<NativeSelect
name="partySlot"
value={record?.partySlot ?? ''}
class="mt-1 w-full"
><option value="">In the PC</option>{#each [1, 2, 3, 4, 5, 6] as slot (slot)}<option
value={slot}>Team slot {slot}</option
>{/each}</NativeSelect
><span class="text-xs text-muted-foreground"
>An occupied slot moves its current Pokémon to the PC.</span
></label
><label
>Obtained by<NativeSelect
name="obtained"
value={record?.obtained ?? 'caught'}
class="mt-1 w-full"
>{#each obtainedMethods as method (method)}<option value={method}>{method}</option
>{/each}</NativeSelect
></label
>
</div>
<fieldset>
<legend class="mb-2 font-medium">Current moves</legend>
<div class="grid grid-cols-2 gap-3">
{#each [0, 1, 2, 3] as slot (slot)}<Input
name="moves"
aria-label={`Move ${slot + 1}`}
placeholder={`Move ${slot + 1}`}
value={record?.moves[slot] ?? ''}
maxlength={60}
/>{/each}
</div>
</fieldset>
<div class="grid gap-4 sm:grid-cols-2">
<label>Nature<Input name="nature" value={record?.nature ?? ''} maxlength={150} /></label
><label>Ability<Input name="ability" value={record?.ability ?? ''} maxlength={150} /></label
><label>Poké Ball<Input name="ball" value={record?.ball ?? ''} maxlength={150} /></label
><label
>Caught on<Input
type="date"
name="caughtOn"
value={record?.caughtOn ?? new Date().toISOString().slice(0, 10)}
/></label
><label
>Location<Input
name="location"
value={record?.location ?? location}
maxlength={150}
/></label
><label
>Collection stage<NativeSelect name="stage" value={record?.stage ?? stage} class="w-full"
>{#each stages as s (s.value)}<option value={s.value}>{s.label}</option
>{/each}</NativeSelect
></label
>
</div>
<label class="flex items-center gap-2"
><Checkbox name="shiny" value="on" checked={record?.shiny ?? false} />Shiny Pokémon</label
>
<label class="block"
>Notes<Textarea
name="notes"
value={record?.notes ?? ''}
maxlength={2000}
placeholder="A lucky encounter, a trade partner, a plan for evolution…"
/></label
>
<Dialog.Footer
><Button variant="outline" onclick={() => (open = false)}>Cancel</Button><Button
type="submit"
disabled={!signedIn || saving}
>{saving ? 'Saving…' : record ? 'Save Pokémon' : 'Add to my collection'}</Button
></Dialog.Footer
>
</form>
{#if record && ondelete}<Button
variant="ghost"
class="justify-self-start text-destructive"
onclick={ondelete}>Remove this catch record</Button
>{/if}
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,91 @@
<script lang="ts">
import { pokemonImage } from './game-art';
let {
groups,
caughtNumbers,
onselect
}: {
groups: [
number,
{
name: string;
type1: string;
type2: string | null;
minLevel: number | null;
maxLevel: number | null;
}[]
][];
caughtNumbers: Set<number>;
onselect: (number: number) => void;
} = $props();
let width = $state(600);
let height = $state(400);
// Fit every species, including broad locations with hundreds of records, into one overview.
const columns = $derived(
Math.max(
1,
Math.min(
groups.length,
Math.ceil(Math.sqrt(((groups.length * width) / Math.max(1, height)) * 0.7))
)
)
);
const rows = $derived(Math.max(1, Math.ceil(groups.length / columns)));
const cellWidth = $derived(width / columns);
const cellHeight = $derived(height / rows);
const showName = $derived(cellWidth >= 58 && cellHeight >= 42);
const showLevel = $derived(cellWidth >= 65 && cellHeight >= 64);
const spriteSize = $derived(
Math.max(
12,
Math.min(48, cellWidth - 8, cellHeight - (showName ? 14 : 0) - (showLevel ? 12 : 0) - 8)
)
);
</script>
<div class="h-full min-h-0 w-full" bind:clientWidth={width} bind:clientHeight={height}>
<div
class="encounter-grid grid gap-1"
role="group"
aria-label="All Pokémon in this area"
style:grid-template-columns={`repeat(${columns}, minmax(0,1fr))`}
style:grid-template-rows={`repeat(${rows}, minmax(0,1fr))`}
style:height={`min(100%, ${rows * 108}px)`}
>
{#each groups as [number, encounters] (number)}
{@const p = encounters[0]}
{@const levels = encounters
.flatMap((e) => [e.minLevel, e.maxLevel])
.filter((n): n is number => n !== null)}
{@const min = levels.length ? Math.min(...levels) : null}
{@const max = levels.length ? Math.max(...levels) : null}
<button
class="relative flex min-h-0 min-w-0 flex-col items-center justify-center overflow-hidden rounded-lg border bg-background p-0.5 hover:bg-muted focus-visible:z-10 focus-visible:outline-2"
class:border-emerald-500={caughtNumbers.has(number)}
aria-label={`${p.name} ${p.type1}${p.type2 ? ` / ${p.type2}` : ''} encounters${caughtNumbers.has(number) ? ', caught' : ''}`}
title={`${p.name} · Lv. ${min ?? '?'}${max !== min ? `–${max}` : ''}${caughtNumbers.has(number) ? ' · caught' : ''}`}
onclick={() => onselect(number)}
>
{#if caughtNumbers.has(number)}<span
class="absolute top-0 right-1 text-[10px] text-emerald-600"
aria-hidden="true">✓</span
>{/if}
<img
src={pokemonImage(number)}
alt=""
width="48"
height="48"
style:width={`${spriteSize}px`}
style:height={`${spriteSize}px`}
class="shrink-0 object-contain"
/>
{#if showName}<span class="block w-full truncate text-center text-[10px] font-medium"
>{p.name}</span
>{/if}
{#if showLevel}<span class="text-[10px] text-muted-foreground"
>Lv. {min ?? '?'}{max !== min ? `–${max}` : ''}</span
>{/if}
</button>
{/each}
</div>
</div>

View File

@@ -0,0 +1,24 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import { base } from '$app/paths';
let {
slug,
name,
class: className = ''
}: { slug: string; name: string; class?: string } = $props();
</script>
<span
class={cn(
'inline-flex h-16 w-28 shrink-0 items-center justify-center overflow-hidden rounded-md',
slug === 'call-of-duty' ? 'bg-zinc-900 p-2' : '',
className
)}
>
<img
src={`${base}/game-logos/${slug}.webp`}
alt={`${name} artwork`}
class={`max-h-full max-w-full object-contain ${slug === 'osu' ? 'rounded-full bg-[#ff66aa]' : ''}`}
loading="lazy"
/>
</span>

View File

@@ -0,0 +1,732 @@
<script lang="ts">
import { pokemonImage } from '$lib/game-tracker/game-art';
import { onMount } from 'svelte';
let hydrated = $state(false);
onMount(() => {
hydrated = true;
});
import { untrack } from 'svelte';
import { deserialize, enhance } from '$app/forms';
import { invalidateAll } from '$app/navigation';
import { SvelteSet } from 'svelte/reactivity';
import * as Tabs from '$lib/components/ui/tabs/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { NativeSelect } from '$lib/components/ui/native-select/index.js';
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
import MapIcon from 'lucide-svelte/icons/map';
import BookOpen from 'lucide-svelte/icons/book-open';
import Archive from 'lucide-svelte/icons/archive';
import Trophy from 'lucide-svelte/icons/trophy';
import ProgressMeter from './ProgressMeter.svelte';
import PokemonProgress from './PokemonProgress.svelte';
import PokemonDetails from '$lib/catch-em-all/PokemonDetails.svelte';
import PokemonMap from './PokemonMap.svelte';
import CatchEditor from './CatchEditor.svelte';
import { stages, firstCatches, type Stage } from './journal';
import { gameRegions, initialLocation, milestonesFor } from './pokemon-world';
import {
availabilityLabels,
availabilityValues,
obtainableAvailability
} from '$lib/availability';
import type { PageData } from '../../routes/projects/game-tracker/pokemon/$types';
import type { PokemonCatch } from '$lib/server/db/schema/tracker-journal';
let { data }: { data: PageData } = $props();
const game = $derived(data.activeGame!);
const gameProgress = $derived(data.progress.games.find((g) => g.id === game.id));
const seenIds = new SvelteSet(untrack(() => data.seenIds));
$effect(() => {
for (const id of data.seenIds) seenIds.add(id);
});
const seenNumbers = $derived(
new Set(data.pokemon.filter((p) => seenIds.has(p.id)).map((p) => p.number))
);
const pendingSeen = new SvelteSet<number>();
async function setSeen(number: number, seen: boolean) {
const species = speciesByNumber.get(number);
if (!species || pendingSeen.has(number)) return;
pendingSeen.add(number);
message = '';
const body = new FormData();
body.set('gameId', String(game.id));
body.set('pokemonId', String(species.id));
body.set('seen', seen ? 'on' : 'off');
try {
const result = deserialize(await (await fetch('?/setSeen', { method: 'POST', body })).text());
if (result.type !== 'success')
throw new Error(
result.type === 'failure'
? String(result.data?.message ?? 'Could not save sighting.')
: 'Could not save sighting.'
);
if (seen) seenIds.add(species.id);
else seenIds.delete(species.id);
pendingSeen.delete(number);
void invalidateAll().catch(() => {
message = 'Sighting saved. Reload to refresh the progress totals.';
});
} catch (error) {
message = error instanceof Error ? error.message : 'Could not save sighting.';
} finally {
pendingSeen.delete(number);
}
}
const regions = $derived(gameRegions(game.slug ?? '', data.regions));
let stage = $state<Stage>(untrack(() => data.journey?.stage ?? 'story'));
let location = $state(
untrack(
() =>
data.journey?.location ??
initialLocation(
data.activeGame?.slug ?? '',
gameRegions(data.activeGame?.slug ?? '', data.regions)[0] ?? ''
)
)
);
let tab = $state('adventure');
let search = $state('');
let dex = $state(untrack(() => data.dexNumbers[0]?.dex ?? 'national'));
let status = $state('all');
let pageNumber = $state(1);
let pcPage = $state(1);
let viewportWidth = $state(1280);
const dexPageSize = $derived(viewportWidth < 640 ? 18 : viewportWidth < 1024 ? 36 : 60);
const pcPageSize = $derived(viewportWidth < 640 ? 12 : 30);
let message = $state('');
let saving = $state(false);
const pending = new SvelteSet<number>();
const caught = $derived(new Set(data.caughtIds));
const dexes = $derived([...new Map(data.dexNumbers.map((d) => [d.dex, d.dexName])).entries()]);
const regionalNumbers = $derived(
new Map(data.dexNumbers.filter((d) => d.dex === dex).map((d) => [d.pokemonId, d.number]))
);
const regionalOrder = $derived(
new Map(
data.pokemon.flatMap((p) => {
const d = data.dexNumbers.find((d) => d.pokemonId === p.id);
return d ? [[p.number, d.number] as const] : [];
})
)
);
const speciesByNumber = $derived(new Map(data.pokemon.map((p) => [p.number, p])));
const first = $derived(firstCatches(data.catches.map((c) => c.catch)));
const caughtNumbers = $derived(
new Set(
data.pokemon
.filter((p) => p.entries.some(([id, g]) => g === game.id && caught.has(id)))
.map((p) => p.number)
)
);
const party = $derived(
data.catches
.filter((c) => c.catch.partySlot !== null)
.sort((a, b) => a.catch.partySlot! - b.catch.partySlot!)
);
const inGame = $derived(data.pokemon.filter((p) => p.entries.some(([, g]) => g === game.id)));
const owned = $derived(inGame.filter((p) => caughtNumbers.has(p.number)));
const filtered = $derived(
inGame
.filter((p) => {
if (dex !== 'national' && !regionalNumbers.has(p.id)) return false;
if (
(status === 'caught' && !caughtNumbers.has(p.number)) ||
(status === 'missing' && caughtNumbers.has(p.number))
)
return false;
return `${p.name} ${p.number} ${p.type1} ${p.type2 ?? ''}`
.toLowerCase()
.includes(search.toLowerCase().trim());
})
.sort((a, b) =>
dex === 'national'
? a.number - b.number
: (regionalNumbers.get(a.id) ?? 0) - (regionalNumbers.get(b.id) ?? 0)
)
);
const pageCount = $derived(Math.max(1, Math.ceil(filtered.length / dexPageSize)));
const pcSpecies = $derived(
owned.filter((p) =>
`${p.name} ${p.number} ${first.get(p.id)?.nickname ?? ''}`
.toLowerCase()
.includes(search.toLowerCase().trim())
)
);
const visiblePc = $derived(pcSpecies.slice((pcPage - 1) * pcPageSize, pcPage * pcPageSize));
$effect(() => {
if (pageNumber > pageCount) pageNumber = pageCount;
});
$effect(() => {
if (pcPage > Math.max(1, Math.ceil(pcSpecies.length / pcPageSize))) pcPage = 1;
});
const milestones = $derived([
...milestonesFor(game.slug ?? '', regions[0] ?? '').map((m) => ({
...m,
done: data.milestones.find((r) => r.key === m.key)?.done ?? false
})),
...data.milestones.filter((m) => m.key.startsWith('custom:'))
]);
const stageMilestones = $derived(milestones.filter((m) => m.stage === stage));
let milestoneOpen = $state(false);
let milestoneMessage = $state('');
let detailsNumber = $state<number | null>(null);
let catchNumber = $state<number | null>(null);
let catchOpen = $state(false);
let catchRecord = $state<PokemonCatch | null>(null);
let deleteOpen = $state(false);
const catchSpecies = $derived(catchNumber === null ? null : speciesByNumber.get(catchNumber));
function editCatch(number: number, record: PokemonCatch | null = null) {
catchNumber = number;
catchRecord = record;
catchOpen = true;
}
function openSpecies(number: number) {
detailsNumber = number;
}
async function toggle(entryId: number, checked: boolean) {
if (pending.has(entryId)) return;
pending.add(entryId);
message = '';
const body = new FormData();
body.set('entryId', String(entryId));
if (checked) body.set('caught', 'on');
try {
const r = deserialize(
await (await fetch('?/togglePokemonCaught', { method: 'POST', body })).text()
);
if (r.type !== 'success')
throw new Error(
r.type === 'failure' ? String(r.data?.message ?? 'Could not save.') : 'Could not save.'
);
await invalidateAll();
} catch (e) {
message = e instanceof Error ? e.message : 'Could not save.';
} finally {
pending.delete(entryId);
}
}
async function removeCatch() {
if (!catchRecord) return;
saving = true;
const body = new FormData();
body.set('id', String(catchRecord.id));
try {
const result = deserialize(
await (await fetch('?/deleteCatch', { method: 'POST', body })).text()
);
if (result.type !== 'success') throw new Error('Could not remove catch.');
await invalidateAll();
catchOpen = false;
deleteOpen = false;
} catch (e) {
message = e instanceof Error ? e.message : 'Could not remove catch.';
} finally {
saving = false;
}
}
</script>
<svelte:window bind:innerWidth={viewportWidth} />
<PokemonProgress progress={data.progress} gameId={game.id} />
{#if message}<p role="alert" class="mt-4 text-destructive">{message}</p>{/if}
<div class="mt-2 grid items-center gap-2 lg:grid-cols-[minmax(280px,.8fr)_minmax(0,2fr)]">
<div>
<form
method="POST"
action="?/saveJourney"
class="flex flex-wrap items-end gap-2"
use:enhance={() => {
saving = true;
message = '';
return async ({ result, update }) => {
await update({ reset: false });
saving = false;
message =
result.type === 'success'
? 'Adventure saved.'
: result.type === 'failure'
? String(result.data?.message ?? 'Could not save.')
: 'Could not save.';
};
}}
>
<input type="hidden" name="gameId" value={game.id} /><input
type="hidden"
name="location"
value={location}
/>
<label class="min-w-40 max-w-xs flex-1 text-xs font-medium"
>My playthrough<NativeSelect class="mt-1 w-full" name="stage" bind:value={stage}
>{#each stages as s (s.value)}<option value={s.value}>{s.label}</option
>{/each}</NativeSelect
></label
>
<Button type="submit" size="sm" variant="outline" disabled={!data.user || saving}
>Save my place</Button
>
</form>
<form
method="POST"
action="?/setCompleted"
use:enhance
class="mt-1 flex items-center gap-2 text-[10px] text-muted-foreground"
>
<input type="hidden" name="gameId" value={game.id} />
<input type="hidden" name="completed" value={data.journey?.completed ? 'off' : 'on'} />
<Button
type="submit"
variant="ghost"
size="xs"
class="h-5 text-[10px]"
disabled={!data.user ||
!hydrated ||
(!!gameProgress?.story.total && gameProgress.story.value === gameProgress.story.total)}
>{gameProgress?.completed ? '✓ Story complete' : 'Mark story complete'}</Button
>
{#if gameProgress}<span
>{gameProgress.story.value} / {gameProgress.story.total} story milestones</span
>{/if}
</form>
</div>
<section class="rounded-xl border bg-card p-2" aria-label="Current team">
<div class="mb-1 flex items-center justify-between px-1 text-xs">
<h2 class="font-semibold">Current team</h2>
<span class="text-xs text-muted-foreground">{party.length} / 6 in your team</span>
</div>
<div class={`grid grid-cols-6 gap-1 ${party.length ? '' : 'max-sm:hidden'}`}>
{#each [1, 2, 3, 4, 5, 6] as slot (slot)}
{@const member = party.find((p) => p.catch.partySlot === slot)}
{#if member}<button
class="flex min-w-0 items-center gap-1 rounded-lg border bg-muted/30 p-1 text-left transition hover:bg-muted max-sm:flex-col max-sm:text-center"
onclick={() => editCatch(member.number, member.catch)}
><img
src={pokemonImage(member.number, member.catch.shiny ? 'shiny' : 'sprite')}
alt={member.name}
width="40"
height="40"
class="size-10 shrink-0"
/><span class="min-w-0 max-w-full flex-1"
><span class="block truncate text-[11px] font-semibold"
>{member.catch.nickname || member.name}</span
><span class="block text-[11px] text-muted-foreground"
>Lv. {member.catch.level}{member.catch.shiny ? ' · ✦' : ''}</span
></span
></button
>
{:else}<button
class="flex min-h-12 items-center justify-center gap-1 rounded-lg border border-dashed text-muted-foreground hover:bg-muted/30 max-sm:flex-col"
onclick={() => (tab = 'pc')}
aria-label={`Choose a Pokémon for team slot ${slot}`}
><span class="text-xl">+</span><span class="mt-1 text-xs">Slot {slot}</span></button
>{/if}
{/each}
</div>
</section>
</div>
<Tabs.Root bind:value={tab} class="mt-3">
<Tabs.List
class="sticky top-0 z-20 mb-3 flex h-auto w-full flex-wrap justify-start gap-1 border-b bg-background/95 p-1"
><Tabs.Trigger
disabled={!hydrated}
value="adventure"
class="px-3 py-2.5"
aria-label="Explore the map"
><MapIcon class="mr-1.5 size-4" aria-hidden="true" /><span class="sm:hidden">Map</span><span
class="hidden sm:inline">Explore the map</span
></Tabs.Trigger
><Tabs.Trigger disabled={!hydrated} value="dex" class="px-3 py-2.5"
><BookOpen class="mr-1.5 size-4" aria-hidden="true" />Pokédex</Tabs.Trigger
><Tabs.Trigger disabled={!hydrated} value="pc" class="px-3 py-2.5"
><Archive class="mr-1.5 size-4" aria-hidden="true" />My PC · {owned.length}</Tabs.Trigger
><Tabs.Trigger
disabled={!hydrated}
value="milestones"
class="px-3 py-2.5"
aria-label="Badges & milestones"
><Trophy class="mr-1.5 size-4" aria-hidden="true" /><span class="sm:hidden">Badges</span><span
class="hidden sm:inline">Badges & milestones</span
></Tabs.Trigger
></Tabs.List
>
<Tabs.Content value="adventure">
<PokemonMap
{stage}
{regionalOrder}
gameId={game.id}
{regions}
locations={data.locations}
bind:location
{caughtNumbers}
{seenNumbers}
{pendingSeen}
onseen={setSeen}
onpokemon={openSpecies}
oncatch={(number) => editCatch(number)}
canRegister={!!data.user && hydrated}
/>
</Tabs.Content>
<Tabs.Content value="dex">
{#if gameProgress}<div class="mb-3 grid grid-cols-2 gap-4">
<ProgressMeter
label={dex === 'national' ? 'Game collection' : 'Regional dex caught'}
progress={dex === 'national' ? gameProgress.collection : gameProgress.regional}
compact
/><ProgressMeter
label={dex === 'national' ? 'Seen in this game' : 'Regional dex seen'}
progress={dex === 'national' ? gameProgress.seen : gameProgress.regionalSeen}
color="sky"
compact
/>
</div>{/if}
<div class="mb-5 grid gap-3 sm:grid-cols-[1fr_1fr_10rem]">
<Input
aria-label="Search Pokémon"
type="search"
placeholder="Name, number or type…"
bind:value={search}
oninput={() => (pageNumber = 1)}
/><NativeSelect
aria-label="Pokédex order"
class="w-full"
bind:value={dex}
onchange={() => (pageNumber = 1)}
>{#each dexes as [key, name] (key)}<option value={key}>Regional · {name}</option
>{/each}<option value="national">National Pokédex</option></NativeSelect
><NativeSelect
aria-label="Catch filter"
class="w-full"
bind:value={status}
onchange={() => (pageNumber = 1)}
><option value="all">All Pokémon</option><option value="missing">Still to catch</option
><option value="caught">Caught</option></NativeSelect
>
</div>
<p class="mb-4 text-sm text-muted-foreground">
Showing {Math.min(dexPageSize, filtered.length - (pageNumber - 1) * dexPageSize)} of {filtered.length}
Pokémon · {dex === 'national' ? 'National order' : 'Regional order'}{!dexes.length
? ' · No regional Pokédex is recorded for this title.'
: ''}
</p>
<div class="grid grid-cols-2 gap-2 sm:grid-cols-4 lg:grid-cols-6">
{#each filtered.slice((pageNumber - 1) * dexPageSize, pageNumber * dexPageSize) as p (p.id)}
{@const entry = p.entries.find(([, g]) => g === game.id)!}{@const code =
availabilityValues[entry[2]]}{@const isCaught = caught.has(entry[0])}
<article
class={`overflow-hidden rounded-xl border bg-card ${isCaught ? 'border-emerald-600/40' : ''}`}
>
<button
class="w-full p-2 text-left hover:bg-muted/40"
onclick={() => openSpecies(p.number)}
><div class="flex items-center justify-between text-xs text-muted-foreground">
<span>#{dex === 'national' ? p.number : regionalNumbers.get(p.id)}</span
>{#if isCaught}<span class="text-emerald-600">✓ caught</span>{/if}
</div>
<img
src={pokemonImage(p.number)}
alt=""
width="96"
height="96"
loading="lazy"
class="mx-auto size-12"
/>
<p class="truncate text-sm font-semibold">{p.name}</p>
<p class="mt-1 text-[11px] text-muted-foreground">
{p.type1}{p.type2 ? ` / ${p.type2}` : ''}
</p>
<p class="mt-1 truncate text-[10px] text-muted-foreground" title={entry[3]}>
{availabilityLabels[code]}
</p></button
>
<div class="flex items-center justify-between gap-2 border-t px-3 py-2">
<label class="flex items-center gap-1 text-[10px]"
><Checkbox
aria-label={`Seen ${p.name} in ${game.name}`}
checked={isCaught || seenIds.has(p.id)}
disabled={!hydrated || !data.user || isCaught || pendingSeen.has(p.number)}
onCheckedChange={(v) => setSeen(p.number, v)}
/>seen</label
>
<label class="flex items-center gap-2 text-xs"
><Checkbox
aria-label={`Caught ${p.name} in ${game.name}`}
checked={isCaught}
disabled={!data.user ||
pending.has(entry[0]) ||
(!isCaught && !obtainableAvailability.has(code))}
onCheckedChange={(v) => toggle(entry[0], v)}
/>caught</label
><Button
variant="ghost"
size="xs"
disabled={!hydrated || !data.user}
onclick={() => editCatch(p.number)}>+ log</Button
>
</div>
</article>
{:else}<p class="col-span-full py-10 text-center text-muted-foreground">
No Pokémon match these filters.
</p>{/each}
</div>
<div class="mt-5 flex items-center justify-between">
<Button variant="outline" disabled={pageNumber === 1} onclick={() => pageNumber--}
>Previous</Button
><span class="text-sm text-muted-foreground">{pageNumber} / {pageCount}</span><Button
variant="outline"
disabled={pageNumber === pageCount}
onclick={() => pageNumber++}>Next</Button
>
</div>
</Tabs.Content>
<Tabs.Content value="pc">
<div class="mb-5 flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="text-xl font-semibold">Your living collection</h2>
<p class="mt-1 text-sm text-muted-foreground">
National order · first registered catch per species · {data.catches.length} individual records
</p>
</div>
<Input
class="max-w-xs"
aria-label="Search your PC"
placeholder="Species or nickname…"
bind:value={search}
oninput={() => (pcPage = 1)}
/>
</div>
<div class="mb-5">
{#if gameProgress}<ProgressMeter
label="PC collection"
progress={gameProgress.collection}
/>{/if}
</div>
<div class="grid grid-cols-3 gap-2 sm:grid-cols-6">
{#each visiblePc as p (p.id)}{@const record = first.get(p.id)}{@const copies =
data.catches.filter((c) => c.catch.pokemonId === p.id).length}
<button
class="relative rounded-xl border bg-card p-2 text-center hover:bg-muted/60"
onclick={() => (record ? editCatch(p.number, record) : openSpecies(p.number))}
><span class="absolute top-2 left-2 text-[10px] text-muted-foreground">#{p.number}</span
>{#if copies > 1}<span class="absolute top-2 right-2 text-[10px]">×{copies}</span
>{/if}<img
src={pokemonImage(p.number, record?.shiny ? 'shiny' : 'sprite')}
alt={p.name}
width="56"
height="56"
loading="lazy"
class="mx-auto"
/><span class="block truncate text-xs font-semibold">{record?.nickname || p.name}</span
><span class="text-[10px] text-muted-foreground"
>{record
? `Lv. ${record.level}${record.partySlot ? ' · team' : ''}`
: 'Add catch details'}</span
></button
>
{:else}<div class="col-span-full rounded-xl border border-dashed px-5 py-12 text-center">
<h3 class="font-semibold">A new adventure starts with one companion.</h3>
<p class="mt-2 text-sm text-muted-foreground">
Register your starter in the Pokédex, or explore a route to find your next catch.
</p>
<Button class="mt-4" variant="outline" onclick={() => (tab = 'dex')}
>Find my starter</Button
>
</div>{/each}
</div>
{#if pcSpecies.length > pcPageSize}<div class="mt-5 flex justify-between">
<Button variant="outline" disabled={pcPage === 1} onclick={() => pcPage--}
>Previous box</Button
><span>Box {pcPage} / {Math.ceil(pcSpecies.length / pcPageSize)}</span><Button
variant="outline"
disabled={pcPage * pcPageSize >= pcSpecies.length}
onclick={() => pcPage++}>Next box</Button
>
</div>{/if}
</Tabs.Content>
<Tabs.Content value="milestones">
{#if gameProgress}<div class="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-4">
<ProgressMeter
label="Story milestones"
progress={gameProgress.story}
color="amber"
/><ProgressMeter
label="Badges & trials"
progress={gameProgress.badges}
color="amber"
/><ProgressMeter
label="Postgame"
progress={gameProgress.postgame}
color="violet"
/><ProgressMeter label="Transfers & events" progress={gameProgress.transfers} color="sky" />
</div>{/if}
<div class="mb-5 flex items-center justify-between gap-3">
<div>
<h2 class="text-xl font-semibold">Milestones along the way</h2>
<p class="mt-1 text-sm text-muted-foreground">
{stageMilestones.filter((m) => m.done).length} / {stageMilestones.length} · {stages.find(
(s) => s.value === stage
)?.label}
</p>
</div>
<Button
variant="outline"
disabled={!hydrated || !data.user}
onclick={() => (milestoneOpen = true)}>+ Custom milestone</Button
>
</div>
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{#each stageMilestones as m (m.key)}
<form
method="POST"
action="?/saveMilestone"
use:enhance
class="flex items-center gap-3 rounded-xl border bg-card p-4"
>
<input type="hidden" name="gameId" value={game.id} /><input
type="hidden"
name="key"
value={m.key}
/><input type="hidden" name="title" value={m.title} /><input
type="hidden"
name="category"
value={m.category}
/><input type="hidden" name="stage" value={m.stage} /><input
type="hidden"
name="done"
value={m.done ? '' : 'on'}
/><Button
type="submit"
variant={m.done ? 'secondary' : 'outline'}
size="icon"
disabled={!hydrated || !data.user}
aria-label={`${m.done ? 'Undo' : 'Complete'} ${m.title}`}
>{m.done ? '✓' : m.category === 'badge' ? '◇' : '○'}</Button
>
<div>
<p class="text-sm font-semibold">{m.title}</p>
<p class="mt-1 text-xs text-muted-foreground">{m.category}</p>
</div>
</form>
{/each}
</div>
</Tabs.Content>
</Tabs.Root>
<Dialog.Root
open={detailsNumber !== null}
onOpenChange={(v) => {
if (!v) detailsNumber = null;
}}
><Dialog.Content class="max-h-[90dvh] overflow-y-auto sm:max-w-3xl"
><Dialog.Header
><Dialog.Title>Pokédex field notes</Dialog.Title><Dialog.Description
>Encounters, evolution, stats and your own catches in {game.name}.</Dialog.Description
></Dialog.Header
>
{#if detailsNumber !== null}
{@const p = speciesByNumber.get(detailsNumber)}
<div class="rounded-xl border p-4">
<div class="flex items-center justify-between gap-2">
<h3 class="font-semibold">My {p?.name} collection</h3>
<Button
size="sm"
disabled={!hydrated || !data.user}
onclick={() => editCatch(detailsNumber!)}>Register a catch</Button
>
</div>
<div class="mt-3 space-y-2">
{#each data.catches.filter((c) => c.number === detailsNumber) as c (c.catch.id)}<button
class="flex w-full items-center justify-between rounded-lg bg-muted/40 p-3 text-left"
onclick={() => editCatch(c.number, c.catch)}
><span
><span class="font-semibold">{c.catch.nickname || c.name}</span><span
class="mt-1 block text-xs text-muted-foreground"
>{c.catch.moves.join(' · ') || 'No moves recorded'}</span
></span
><span class="text-xs"
>Lv. {c.catch.level}{c.catch.partySlot ? ` · slot ${c.catch.partySlot}` : ''}</span
></button
>{:else}<p class="text-sm text-muted-foreground">
No individual catches registered yet.
</p>{/each}
</div>
</div>
<PokemonDetails
number={detailsNumber}
gameId={game.id}
isAdmin={data.isAdmin}
onsaved={() => invalidateAll()}
/>
{/if}
</Dialog.Content></Dialog.Root
>
{#if catchSpecies}{#key `${catchSpecies.id}-${catchRecord?.id ?? 'new'}`}<CatchEditor
bind:open={catchOpen}
species={catchSpecies}
gameId={game.id}
record={catchRecord}
{stage}
location={data.locations.find((p) => p.slug === location)?.name ?? location}
signedIn={!!data.user}
ondelete={() => (deleteOpen = true)}
ondetails={() => {
catchOpen = false;
detailsNumber = catchNumber;
}}
/>{/key}{/if}
<AlertDialog.Root bind:open={deleteOpen}
><AlertDialog.Content
><AlertDialog.Header
><AlertDialog.Title>Remove this catch record?</AlertDialog.Title><AlertDialog.Description
>This removes its nickname, moves and party slot. Other catches and the historical caught
checkmark remain saved.</AlertDialog.Description
></AlertDialog.Header
><AlertDialog.Footer
><AlertDialog.Cancel>Keep Pokémon</AlertDialog.Cancel><Button
variant="destructive"
onclick={removeCatch}
disabled={saving}>Remove record</Button
></AlertDialog.Footer
></AlertDialog.Content
></AlertDialog.Root
>
<Dialog.Root bind:open={milestoneOpen}
><Dialog.Content
><Dialog.Header
><Dialog.Title>Set a new milestone</Dialog.Title><Dialog.Description
>A battle, a ribbon, a rare encounter, or your own challenge.</Dialog.Description
></Dialog.Header
>
{#if milestoneMessage}<p role="alert" class="text-destructive">{milestoneMessage}</p>{/if}
<form
method="POST"
action="?/saveMilestone"
class="space-y-4"
use:enhance={() =>
async ({ result, update }) => {
await update();
if (result.type === 'success') {
milestoneOpen = false;
milestoneMessage = '';
} else
milestoneMessage =
result.type === 'failure'
? String(result.data?.message ?? 'Could not save milestone.')
: 'Could not save milestone.';
}}
>
<input type="hidden" name="gameId" value={game.id} /><input
type="hidden"
name="stage"
value={stage}
/><input type="hidden" name="category" value="event" /><label class="block"
>Milestone<Input name="title" required maxlength={200} /></label
><Button type="submit">Add milestone</Button>
</form></Dialog.Content
></Dialog.Root
>

View File

@@ -0,0 +1,534 @@
<script lang="ts">
import { pokemonImage } from '$lib/game-tracker/game-art';
import { untrack } from 'svelte';
import { base } from '$app/paths';
import { Slider } from '$lib/components/ui/slider/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { NativeSelect } from '$lib/components/ui/native-select/index.js';
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import * as Tabs from '$lib/components/ui/tabs/index.js';
import EncounterGrid from './EncounterGrid.svelte';
import ProgressMeter from './ProgressMeter.svelte';
import { fitViewport } from './fit-viewport';
import { type AtlasNode } from './pokemon-world';
import mapArtwork from '../../../data/pokemon-map-art.json';
const regionMaps: Record<string, (typeof mapArtwork)['sinnoh']> = mapArtwork;
type Place = { id: number; slug: string; name: string; region: string | null; count: number };
type Encounter = {
pokemonId: number;
number: number;
name: string;
type1: string;
type2: string | null;
area: string | null;
method: string;
minLevel: number | null;
maxLevel: number | null;
rarity: number | null;
conditions: string[];
form: string | null;
note: string | null;
};
let {
gameId,
regions,
locations,
location = $bindable(''),
caughtNumbers,
seenNumbers,
pendingSeen,
onseen,
onpokemon,
oncatch,
canRegister,
stage,
regionalOrder
}: {
stage: string;
regionalOrder: Map<number, number>;
gameId: number;
regions: string[];
locations: Place[];
location?: string;
caughtNumbers: Set<number>;
seenNumbers: Set<number>;
pendingSeen: Set<number>;
onseen: (number: number, seen: boolean) => void;
onpokemon: (number: number) => void;
oncatch: (number: number) => void;
canRegister: boolean;
} = $props();
let region = $state(
untrack(() => {
const saved = locations.find((p) => p.slug === location)?.region?.toLowerCase();
return saved && regions.includes(saved) ? saved : (regions[0] ?? '');
})
);
let zoom = $state(1);
let filter = $state('');
let method = $state('all');
let area = $state('all');
let mobileView = $state('encounters');
let overviewOpen = $state(false);
let encounterNumber = $state<number | null>(null);
const detailRows = $derived(groups.find(([n]) => n === encounterNumber)?.[1] ?? []);
const areas = $derived([...new Set(encounters.flatMap((e) => (e.area ? [e.area] : [])))]);
let encounters = $state<Encounter[]>([]);
let loading = $state(true);
let loadError = $state('');
let retry = $state(0);
let includeSpecial = $derived(stage !== 'story');
let viewport: HTMLDivElement;
let viewportWidth = $state(640);
let viewportHeight = $state(560);
let showNames = $state(false);
const mapWidth = $derived(
map ? Math.min(viewportWidth, (viewportHeight * map.width) / map.height) * zoom : viewportWidth
);
let dragging: { x: number; y: number; left: number; top: number } | null = null;
const map = $derived(regionMaps[region]);
const places = $derived(
locations
.filter((p) => p.region?.toLowerCase() === region || (regions.length === 1 && p.count > 0))
.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }))
);
const selected = $derived(
locations.find(
(p) => p.slug === location && (p.region?.toLowerCase() === region || p.count > 0)
)
);
const selectedLocationId = $derived(selected?.id);
const node = $derived(map?.nodes.find((n) => n.slug === location));
const methods = $derived([...new Set(encounters.map((e) => e.method))]);
const special = (e: Encounter) =>
e.conditions.some((c) => /pok.radar|swarm|slot 2|national|radio/i.test(c));
const groups = $derived(
[
...Map.groupBy(
encounters.filter(
(e) =>
(area === 'all' || e.area === area) &&
(method === 'all' || e.method === method) &&
(includeSpecial || !special(e))
),
(e) => e.number
)
].sort(([a], [b]) => (regionalOrder.get(a) ?? a + 10000) - (regionalOrder.get(b) ?? b + 10000))
);
const hiddenSpecial = $derived(encounters.filter(special).length);
const placeMap = $derived(new Map(places.map((p) => [p.slug, p])));
const choices = $derived(
[
...new Map(
[
...places.map((p) => ({ slug: p.slug, name: p.name, count: p.count })),
...(map?.nodes ?? [])
.filter((n) => !placeMap.has(n.slug))
.map((n) => ({ slug: n.slug, name: n.name, count: 0 }))
].map((p) => [p.slug, p])
).values()
].filter((p) => p.name.toLowerCase().includes(filter.toLowerCase()))
);
$effect(() => {
if (!regions.includes(region)) region = regions[0] ?? '';
});
$effect(() => {
const id = selectedLocationId;
const game = gameId;
void retry;
encounters = [];
method = 'all';
area = 'all';
encounterNumber = null;
loadError = '';
loading = !!id;
if (!id) return;
const controller = new AbortController();
fetch(`${base}/projects/game-tracker/pokemon/api/encounters?game=${game}&location=${id}`, {
signal: controller.signal
})
.then((r) => {
if (!r.ok) throw new Error('Could not load this location.');
return r.json();
})
.then((rows: Encounter[]) => {
if (!controller.signal.aborted) encounters = rows;
})
.catch((e) => {
if (!controller.signal.aborted) loadError = e.message;
})
.finally(() => {
if (!controller.signal.aborted) loading = false;
});
return () => controller.abort();
});
function choose(point: AtlasNode) {
location = point.slug;
mobileView = 'encounters';
}
</script>
<Tabs.Root bind:value={mobileView} class="mb-2 lg:hidden"
><Tabs.List class="w-full"
><Tabs.Trigger value="encounters" class="flex-1">Area Pokémon</Tabs.Trigger><Tabs.Trigger
value="map"
class="flex-1">Region map</Tabs.Trigger
></Tabs.List
></Tabs.Root
>
<div class="grid gap-3 lg:grid-cols-[minmax(0,.85fr)_minmax(0,1.15fr)]">
<section
class={`min-w-0 overflow-hidden rounded-xl border bg-card ${mobileView === 'map' ? '' : 'max-lg:hidden'}`}
>
<div class="flex flex-wrap items-center justify-between gap-2 border-b px-3 py-2">
<div>
<p class="text-xs font-semibold tracking-[.18em] text-muted-foreground uppercase">
Field atlas
</p>
<h2 class="text-xl font-semibold">{map?.name ?? 'Region map'}</h2>
</div>
<div class="flex items-center gap-2">
{#if regions.length > 1}<NativeSelect
aria-label="Map region"
bind:value={region}
onchange={() => {
location = '';
zoom = 1;
}}
>{#each regions as r (r)}<option value={r}>{regionMaps[r]?.name ?? r}</option
>{/each}</NativeSelect
>{/if}
<Button
variant="outline"
size="icon"
aria-label="Zoom out"
disabled={zoom <= 1}
onclick={() => (zoom = Math.max(1, zoom - 0.5))}>−</Button
>
<div class="hidden w-24 sm:block">
<Slider
type="single"
min={1}
max={4}
step={0.5}
bind:value={zoom}
aria-label="Map zoom"
/>
</div>
<Button variant="ghost" size="sm" onclick={() => (zoom = 1)} aria-label="Reset map zoom"
>{Math.round(zoom * 100)}%</Button
>
<Button
variant="outline"
size="icon"
aria-label="Zoom in"
disabled={zoom >= 4}
onclick={() => (zoom = Math.min(4, zoom + 0.5))}>+</Button
>
</div>
</div>
<!-- Scrolling and the location picker offer keyboard alternatives to dragging. -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex (scrollable map viewport must be keyboard focusable) -->
<div
bind:this={viewport}
bind:clientWidth={viewportWidth}
bind:clientHeight={viewportHeight}
class="atlas-viewport h-80 overflow-auto"
use:fitViewport={{ minimum: 180, bottom: 64 }}
role="region"
aria-label={`${map?.name} interactive map`}
tabindex="0"
onpointerdown={(e) => {
if (e.target instanceof Element && e.target.closest('button')) return;
dragging = {
x: e.clientX,
y: e.clientY,
left: viewport.scrollLeft,
top: viewport.scrollTop
};
viewport.setPointerCapture(e.pointerId);
}}
onpointermove={(e) => {
if (dragging) {
viewport.scrollLeft = dragging.left - (e.clientX - dragging.x);
viewport.scrollTop = dragging.top - (e.clientY - dragging.y);
}
}}
onpointerup={() => (dragging = null)}
onpointercancel={() => (dragging = null)}
>
{#if map}
<div
class="atlas-canvas relative mx-auto"
style:width={`${mapWidth}px`}
style:height={`${(mapWidth * map.height) / map.width}px`}
>
<img
src={`${base}${map.image}`}
alt={`${map.name} region map from the Pokémon games`}
width={map.width}
height={map.height}
draggable="false"
class="absolute inset-0 h-full w-full select-none"
style:image-rendering={map.pixelated ? 'pixelated' : 'auto'}
/>
{#each map.nodes as point (point.slug)}
<button
type="button"
class="map-pin absolute -translate-x-1/2 -translate-y-1/2 rounded-full border-2 shadow-sm transition-colors focus-visible:z-30 focus-visible:outline-2 focus-visible:outline-offset-4"
class:selected={location === point.slug}
class:town={point.kind === 'town'}
style:left={`${(point.x / map.width) * 100}%`}
style:top={`${(point.y / map.height) * 100}%`}
title={`${point.name} · ${placeMap.get(point.slug)?.count ?? 0} recorded species`}
aria-label={`Explore ${point.name}`}
aria-pressed={location === point.slug}
onclick={() => choose(point)}
>
<span
class="pin-label pointer-events-none absolute bottom-full left-1/2 mb-1 -translate-x-1/2 whitespace-nowrap rounded bg-background/95 px-1.5 py-0.5 text-[10px] font-semibold text-foreground shadow-sm"
class:visible-label={(showNames && point.kind === 'town') ||
location === point.slug}>{point.name}</span
>
</button>
{/each}
<div
class="hidden pointer-events-none absolute bottom-4 left-4 rounded-lg border bg-background/90 px-3 py-2 text-xs text-muted-foreground"
>
<span class="mr-2 text-amber-500">●</span>selected location · drag to explore
</div>
</div>
{:else}<p class="p-8 text-muted-foreground">
No regional map is recorded for this title yet.
</p>{/if}
</div>
<div
class="flex flex-wrap justify-between gap-2 border-t px-4 py-3 text-xs text-muted-foreground"
>
<label class="flex items-center gap-2"><Checkbox bind:checked={showNames} />Town names</label
><span>{map?.precision}</span>{#if map}<!-- Catalog or validated external URL. -->
<!-- eslint-disable svelte/no-navigation-without-resolve -->
<a href={map.source} target="_blank" rel="noreferrer" class="underline">map source ↗</a>
<!-- eslint-enable svelte/no-navigation-without-resolve -->{/if}
</div>
</section>
<section
class={`min-w-0 rounded-xl border bg-card p-3 ${mobileView === 'encounters' ? '' : 'max-lg:hidden'}`}
aria-label="Area encounters"
>
<div class="mb-2 flex items-center justify-between gap-2">
<div>
<h2 class="text-base font-semibold">
{selected?.name ?? node?.name ?? 'Choose a location'}
</h2>
<p class="text-[11px] text-muted-foreground">
{groups.length} Pokémon · {groups.filter(([n]) => caughtNumbers.has(n)).length} caught
</p>
</div>
<Button
variant="outline"
size="sm"
onclick={() => (overviewOpen = true)}
disabled={!groups.length}>Expand encounters</Button
>
</div>
<div class="grid grid-cols-2 gap-2">
<Input
type="search"
aria-label="Find a location"
placeholder="Find a route…"
bind:value={filter}
/>
<NativeSelect class="w-full" aria-label="Encounter location" bind:value={location}
><option value="">Choose a location</option>{#each choices as p (p.slug)}<option
value={p.slug}>{p.name}</option
>{/each}</NativeSelect
>
{#if areas.length > 1}<NativeSelect aria-label="Encounter area" bind:value={area}
><option value="all">All areas / floors</option>{#each areas as a (a)}<option value={a}
>{a}</option
>{/each}</NativeSelect
>{/if}
{#if methods.length > 1}<NativeSelect aria-label="Encounter method" bind:value={method}
><option value="all">All encounter methods</option>{#each methods as m (m)}<option
value={m}>{m}</option
>{/each}</NativeSelect
>{/if}
</div>
<div class="mt-2 grid grid-cols-2 gap-4">
<ProgressMeter
label="Area caught"
progress={{
value: groups.filter(([n]) => caughtNumbers.has(n)).length,
total: groups.length
}}
compact
/>
<ProgressMeter
label="Area seen"
progress={{
value: groups.filter(([n]) => caughtNumbers.has(n) || seenNumbers.has(n)).length,
total: groups.length
}}
color="sky"
compact
/>
</div>
{#if hiddenSpecial}<label class="mt-2 flex items-center gap-2 text-[11px] text-muted-foreground"
><Checkbox bind:checked={includeSpecial} />Include special conditions (radar, swarms, other
cartridges)</label
>{/if}
<div class="mt-2 h-64" use:fitViewport={{ bottom: 48, minimum: 120 }} aria-live="polite">
{#if loading}<p class="py-8 text-sm text-muted-foreground">Searching the tall grass…</p>
{:else if loadError}<p role="alert">{loadError}</p>
<Button variant="outline" onclick={() => retry++}>Try again</Button>
{:else if groups.length}<EncounterGrid
{groups}
{caughtNumbers}
onselect={(number) => (encounterNumber = number)}
/>
{:else}<div class="rounded-xl border border-dashed p-5 text-sm text-muted-foreground">
{location
? 'No encounters are recorded here for these filters. Check the location guide for gifts, special events or missing data.'
: 'Pick a marker or choose a location to explore encounters.'}
</div>{/if}
</div>
{#if selected || node}<a
class="mt-2 inline-block text-[10px] text-muted-foreground underline"
href={`https://bulbapedia.bulbagarden.net/wiki/${encodeURIComponent((selected?.name ?? node?.name ?? '').startsWith('Route') ? `${map?.name ?? ''} ${selected?.name ?? node?.name}` : (selected?.name ?? node?.name ?? ''))}`}
target="_blank"
rel="noreferrer">Location guide on Bulbapedia ↗</a
>{/if}
</section>
</div>
<Dialog.Root bind:open={overviewOpen}
><Dialog.Content class="flex h-[90dvh] max-w-[96vw] flex-col gap-3 p-4 sm:max-w-[96vw]"
><Dialog.Header
><Dialog.Title>{selected?.name ?? node?.name} · all {groups.length} Pokémon</Dialog.Title
><Dialog.Description
>Every species matching your area and method filters. Tap a portrait for levels, encounter
conditions and catch registration.</Dialog.Description
></Dialog.Header
>
<div class="min-h-0 flex-1">
<EncounterGrid {groups} {caughtNumbers} onselect={(number) => (encounterNumber = number)} />
</div></Dialog.Content
></Dialog.Root
>
<Dialog.Root
open={encounterNumber !== null}
onOpenChange={(open) => {
if (!open) encounterNumber = null;
}}
>
<Dialog.Content class="max-h-[85dvh] overflow-y-auto sm:max-w-2xl">
<Dialog.Header
><Dialog.Title
>{detailRows[0]?.name ?? 'Pokémon'} · {selected?.name ?? node?.name}</Dialog.Title
><Dialog.Description>All recorded encounters matching your filters.</Dialog.Description
></Dialog.Header
>
{#if encounterNumber !== null}<div class="flex items-center gap-3">
<img src={pokemonImage(encounterNumber)} alt="" width="64" height="64" />
<p class="text-sm">
{detailRows[0]?.type1}{detailRows[0]?.type2
? ` / ${detailRows[0].type2}`
: ''}{caughtNumbers.has(encounterNumber) ? ' · ✓ caught' : ''}
</p>
</div>{/if}
<div class="space-y-2">
{#each detailRows as row, i (i)}<div class="rounded-lg border p-3 text-xs">
<p class="font-medium">
{row.method}{row.area ? ` · ${row.area}` : ''} · Lv. {row.minLevel ??
'?'}{row.maxLevel !== row.minLevel ? `–${row.maxLevel}` : ''}{row.rarity !== null
? ` · ${row.rarity}%`
: ''}
</p>
{#if row.conditions.length || row.form || row.note}<p class="mt-1 text-muted-foreground">
{[row.form, ...row.conditions, row.note].filter(Boolean).join(' · ')}
</p>{/if}
</div>{/each}
</div>
<Dialog.Footer
><Button
variant="secondary"
disabled={!canRegister ||
(encounterNumber !== null &&
(caughtNumbers.has(encounterNumber) || pendingSeen.has(encounterNumber)))}
onclick={() => {
if (encounterNumber !== null) onseen(encounterNumber, !seenNumbers.has(encounterNumber));
}}
>{encounterNumber !== null &&
(seenNumbers.has(encounterNumber) || caughtNumbers.has(encounterNumber))
? '✓ Seen · undo'
: 'Mark as seen'}</Button
><Button
variant="outline"
onclick={() => {
const n = encounterNumber!;
encounterNumber = null;
overviewOpen = false;
onpokemon(n);
}}>Species research</Button
><Button
disabled={!canRegister}
onclick={() => {
const n = encounterNumber!;
encounterNumber = null;
overviewOpen = false;
oncatch(n);
}}>Register a catch</Button
></Dialog.Footer
>
</Dialog.Content>
</Dialog.Root>
<style>
.atlas-viewport {
background: linear-gradient(
135deg,
color-mix(in oklch, var(--background), #549da3 12%),
color-mix(in oklch, var(--background), #8fa965 10%)
);
cursor: grab;
}
.atlas-viewport:active {
cursor: grabbing;
}
.map-pin {
width: 10px;
height: 10px;
background: #6ca68e;
border-color: var(--background);
}
.map-pin.town {
width: 14px;
height: 14px;
background: #ebc481;
}
.map-pin.selected {
background: #f59e0b;
border-color: #fff;
z-index: 20;
box-shadow: 0 0 0 5px #f59e0b40;
}
.pin-label {
visibility: hidden;
}
@media (max-width: 639px) {
.map-pin .pin-label.visible-label {
visibility: hidden;
}
.map-pin.selected .pin-label {
visibility: visible;
}
}
.visible-label,
.map-pin:hover .pin-label,
.map-pin:focus-visible .pin-label {
visibility: visible;
}
</style>

View File

@@ -0,0 +1,104 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import ChartNoAxesCombined from 'lucide-svelte/icons/chart-no-axes-combined';
import ProgressMeter from './ProgressMeter.svelte';
import type { pokemonProgress } from './progress';
let { progress, gameId }: { progress: ReturnType<typeof pokemonProgress>; gameId: number } =
$props();
let open = $state(false);
const current = $derived(progress.games.find((g) => g.id === gameId));
</script>
<section class="mt-2 rounded-lg border bg-card px-3 py-2.5" aria-label="Pokémon completion">
<div class="mb-2 flex items-center justify-between gap-3">
<h2 class="text-xs font-semibold">Quick progress</h2>
<Button variant="ghost" size="xs" class="h-6 gap-1 text-[10px]" onclick={() => (open = true)}
><ChartNoAxesCombined class="size-3.5" aria-hidden="true" />Full breakdown</Button
>
</div>
<div class="grid grid-cols-2 gap-x-5 gap-y-2 sm:grid-cols-4">
{#if current}<ProgressMeter
label={`${current.name} completion`}
progress={current.overall}
color="amber"
compact
hint="Collection, story, postgame and transfers/events contribute equally where available."
/>{/if}
<ProgressMeter
label="National collection"
progress={progress.caught}
compact
hint={`${progress.caught.value} / ${progress.caught.total} unique species caught across selected games.`}
/>
<ProgressMeter
label="Species seen at least once"
progress={progress.seen}
color="sky"
compact
hint={`${progress.seen.value} / ${progress.seen.total} species. Catches count as seen.`}
/>
<ProgressMeter
label="Games completed"
progress={progress.completed}
color="violet"
compact
hint={`${progress.completed.value} / ${progress.completed.total} stories cleared.`}
/>
</div>
</section>
<Dialog.Root bind:open
><Dialog.Content class="max-h-[85dvh] overflow-y-auto sm:max-w-3xl">
<Dialog.Header
><Dialog.Title>Your Pokémon progress</Dialog.Title><Dialog.Description
>Every catch, discovery and milestone adds up. Collection totals exclude unconfirmed
availability unless you have recorded a catch.</Dialog.Description
></Dialog.Header
>
<div class="grid gap-4 sm:grid-cols-3">
<ProgressMeter label="National collection" progress={progress.caught} />
<ProgressMeter label="Species seen at least once" progress={progress.seen} color="sky" />
<ProgressMeter label="Games completed" progress={progress.completed} color="violet" />
<ProgressMeter
label="All adventures"
progress={progress.overall}
color="amber"
detail="Equal weight per selected game"
/>
<ProgressMeter label="All species / game pairs" progress={progress.collection} />
</div>
{#if current}
<h3 class="border-t pt-4 font-semibold">{current.name} · your adventure</h3>
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3">
<ProgressMeter label="Game collection" progress={current.collection} />
<ProgressMeter
label="Obtainable in-game"
progress={current.inGame}
hint="Catch, evolve and breed; trades, events and transfers have their own collection stage."
/>
<ProgressMeter label="Seen in this game" progress={current.seen} color="sky" />
{#if current.regional.total}<ProgressMeter
label="Regional dex caught"
progress={current.regional}
/><ProgressMeter
label="Regional dex seen"
progress={current.regionalSeen}
color="sky"
/>{/if}
{#each [{ label: 'Story milestones', progress: current.story }, { label: 'Badges & trials', progress: current.badges }, { label: 'League battles', progress: current.league }, { label: 'Boss battles', progress: current.bosses }, { label: 'Postgame milestones', progress: current.postgame }, { label: 'Trades, transfers & events', progress: current.transfers }, { label: 'Personal milestones', progress: current.custom }] as metric (metric.label)}
{#if metric.progress.total}<ProgressMeter
label={metric.label}
progress={metric.progress}
color="amber"
/>{/if}
{/each}
</div>
{/if}
<p class="text-xs text-muted-foreground">
Game completion averages collection, story, postgame and transfer/event milestones. Games
completed counts stories you mark cleared, or games with every default story milestone
finished. These percentages describe the imported catalog and your selected games.
</p>
</Dialog.Content></Dialog.Root
>

View File

@@ -0,0 +1,58 @@
<script lang="ts">
import { Progress } from '$lib/components/ui/progress/index.js';
import { fraction, type ProgressCount } from './progress';
let {
label,
progress,
detail,
hint = '',
color = 'emerald',
compact = false
}: {
label: string;
progress: ProgressCount;
detail?: string;
hint?: string;
color?: 'emerald' | 'sky' | 'amber' | 'violet' | 'pink';
compact?: boolean;
} = $props();
const percent = $derived(fraction(progress) * 100);
// Keep tiny catalog gains visible without rounding unfinished progress up to 100%.
const display = $derived(
percent === 100
? '100'
: percent === 0
? '0'
: percent < 0.01
? '<0.01'
: Math.min(99.99, percent).toFixed(2)
);
const colors = {
emerald: '[&_[data-slot=progress-indicator]]:bg-emerald-500',
sky: '[&_[data-slot=progress-indicator]]:bg-sky-500',
amber: '[&_[data-slot=progress-indicator]]:bg-amber-500',
violet: '[&_[data-slot=progress-indicator]]:bg-violet-500',
pink: '[&_[data-slot=progress-indicator]]:bg-pink-500'
};
</script>
<div class="min-w-0 space-y-1" title={hint}>
<div class="flex items-baseline justify-between gap-2">
<span class={compact ? 'truncate text-[10px] text-muted-foreground' : 'text-xs font-medium'}
>{label}</span
>
<span class={`shrink-0 font-semibold tabular-nums ${compact ? 'text-xs' : 'text-sm'}`}
>{display}%{percent === 100 ? ' ✓' : ''}</span
>
</div>
<Progress
value={percent}
max={100}
aria-label={label}
aria-valuetext={`${display}%${detail ? ` · ${detail}` : ''}`}
class={`h-1.5 ${colors[color]} [&_[data-slot=progress-indicator]]:duration-700 [&_[data-slot=progress-indicator]]:ease-out motion-reduce:[&_[data-slot=progress-indicator]]:transition-none`}
/>
{#if !compact}<p class="text-[10px] text-muted-foreground">
{detail ?? `${progress.value.toLocaleString()} / ${progress.total.toLocaleString()}`}
</p>{/if}
</div>

View File

@@ -0,0 +1,46 @@
<script lang="ts">
import { base } from '$app/paths';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import artwork from '../../../data/cod-art.json';
let { game, name, icon }: { game: string; name: string; icon: string | null } = $props();
const source = $derived(
artwork.find((a) => a.game === game && a.name.toLowerCase() === name.toLowerCase())
);
const src = $derived(source ? `${base}${source.path}` : icon);
</script>
{#if src}
<Dialog.Root>
<Dialog.Trigger
class="shrink-0 overflow-hidden rounded-md border focus-visible:outline-2"
aria-label={`View ${name} artwork`}
><img
{src}
alt=""
loading="lazy"
width="112"
height="64"
class="h-16 w-28 object-contain"
/></Dialog.Trigger
>
<Dialog.Content class="sm:max-w-3xl"
><Dialog.Header
><Dialog.Title>{name}</Dialog.Title><Dialog.Description
>{source?.credit ?? 'Weapon artwork'}</Dialog.Description
></Dialog.Header
><img
{src}
alt={`${name} weapon reference for ${game.replaceAll('-', ' ')}`}
class="max-h-[65dvh] w-full object-contain"
/>{#if source}<!-- Catalog or validated external URL. -->
<!-- eslint-disable svelte/no-navigation-without-resolve -->
<a
href={source.source}
target="_blank"
rel="noreferrer"
class="text-xs text-muted-foreground underline">Image source ↗</a
>
<!-- eslint-enable svelte/no-navigation-without-resolve -->{/if}</Dialog.Content
>
</Dialog.Root>
{/if}

View File

@@ -0,0 +1,138 @@
<script lang="ts">
import { deserialize } from '$app/forms';
import { Button } from '$lib/components/ui/button/index.js';
import Minus from 'lucide-svelte/icons/minus';
import Plus from 'lucide-svelte/icons/plus';
let {
itemId,
name,
level,
prestige,
levelCap = null,
prestigeCap = 1000,
prestigeRequiresMaxLevel = false,
compact = false,
disabled,
onupdate,
onedit
}: {
itemId: number;
name: string;
level: number;
prestige: number;
levelCap?: number | null;
prestigeCap?: number;
prestigeRequiresMaxLevel?: boolean;
compact?: boolean;
disabled: boolean;
onupdate: (rank: { itemId: number; level: number; prestige: number }) => void;
onedit: () => void;
} = $props();
let saving = $state(false);
let error = $state('');
let saved = $state(false);
async function adjust(field: 'level' | 'prestige', delta: number) {
if (saving) return;
saving = true;
error = '';
saved = false;
const body = new FormData();
body.set('itemId', String(itemId));
body.set('field', field);
body.set('delta', String(delta));
try {
const result = deserialize(
await (await fetch('?/adjustWeaponRank', { method: 'POST', body })).text()
);
if (result.type !== 'success')
throw new Error(
result.type === 'failure'
? String(result.data?.message ?? 'Could not save.')
: 'Could not save.'
);
const rank = result.data?.rank as { itemId: number; level: number; prestige: number };
onupdate(rank);
saved = true;
} catch (e) {
error = e instanceof Error ? e.message : 'Could not save. Try again.';
} finally {
saving = false;
}
}
</script>
<div class="space-y-1">
<div class="flex flex-wrap items-center gap-3">
<div
class="flex items-center gap-1 rounded-lg border bg-background p-1"
aria-label={`${name} level controls`}
>
<Button
variant="ghost"
size="icon"
class={compact ? 'size-7' : 'size-10'}
aria-label={`Level down ${name}`}
disabled={disabled || saving || level <= 1}
onclick={() => adjust('level', -1)}
><Minus class={compact ? 'size-3' : 'size-4'} aria-hidden="true" /></Button
>
<Button
variant="ghost"
class={compact
? 'h-8 min-w-11 flex-col gap-0 px-1 tabular-nums'
: 'h-10 min-w-16 flex-col gap-0 px-2 tabular-nums'}
aria-label={`Edit ${name} level and prestige`}
disabled={disabled || saving}
onclick={onedit}
><span
class={compact ? 'text-[8px] text-muted-foreground' : 'text-[10px] text-muted-foreground'}
>LEVEL</span
><span class={compact ? 'text-sm font-semibold' : 'text-lg font-semibold'}>{level}{#if levelCap}<span class="text-muted-foreground">/{levelCap}</span>{/if}</span
></Button
>
<Button
variant="secondary"
size="icon"
class={compact ? 'size-7' : 'size-10'}
aria-label={`Level up ${name}`}
disabled={disabled || saving || level >= (levelCap ?? 10000)}
onclick={() => adjust('level', 1)}
><Plus class={compact ? 'size-3' : 'size-4'} aria-hidden="true" /></Button
>
</div>
<div
class="flex items-center gap-1 rounded-lg border bg-background p-1"
aria-label={`${name} prestige controls`}
>
<Button
variant="ghost"
size="icon"
class={compact ? 'size-7' : 'size-10'}
aria-label={`Prestige down ${name}`}
disabled={disabled || saving || prestige <= 0}
onclick={() => adjust('prestige', -1)}
><Minus class={compact ? 'size-3' : 'size-4'} aria-hidden="true" /></Button
>
<span class="min-w-12 text-center tabular-nums"
><span
class={compact
? 'block text-[8px] text-muted-foreground'
: 'block text-[10px] text-muted-foreground'}>PRESTIGE</span
><span class={compact ? 'text-sm font-semibold' : 'text-lg font-semibold'}>{prestige}{#if prestigeCap < 1000}<span class="text-muted-foreground">/{prestigeCap}</span>{/if}</span
></span
>
<Button
variant="secondary"
size="icon"
class={compact ? 'size-7' : 'size-10'}
aria-label={`Prestige up ${name}`}
disabled={disabled || saving || prestige >= prestigeCap || (prestigeRequiresMaxLevel && level < (levelCap ?? 10000))}
onclick={() => adjust('prestige', 1)}
><Plus class={compact ? 'size-3' : 'size-4'} aria-hidden="true" /></Button
>
</div>
</div>
<p class="min-h-3 text-[9px] text-muted-foreground" role="status">
{saving ? 'Saving…' : error || (saved ? 'Saved' : 'Tap the level to edit both values')}
</p>
</div>

View File

@@ -0,0 +1,28 @@
/** Size a companion panel from its real page position, allowing room for its footer. */
export function fitViewport(node: HTMLElement, { minimum = 140, maximum = 560, bottom = 48 } = {}) {
let frame = 0;
const measure = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
if (!node.getClientRects().length) return;
const top = node.getBoundingClientRect().top + window.scrollY;
const height = Math.round(
Math.max(minimum, Math.min(maximum, window.innerHeight - top - bottom))
);
if (node.style.height !== `${height}px`) node.style.height = `${height}px`;
});
};
const observer = new ResizeObserver(measure);
// Ancestors capture team, filters, tabs and responsive header changes above the panel.
for (let parent = node.parentElement; parent; parent = parent.parentElement)
observer.observe(parent);
window.addEventListener('resize', measure);
measure();
return {
destroy() {
observer.disconnect();
window.removeEventListener('resize', measure);
cancelAnimationFrame(frame);
}
};
}

View File

@@ -0,0 +1,15 @@
import releases from '../../../data/pokemon-releases.json';
import { base } from '$app/paths';
const dates: Record<string, string> = releases.dates;
export function pokemonRelease(slug: string | null) {
return dates[slug ?? ''] ?? '';
}
export function chronologicalPokemon<T extends { slug: string | null; id: number }>(games: T[]) {
return [...games].sort(
(a, b) => pokemonRelease(a.slug).localeCompare(pokemonRelease(b.slug)) || a.id - b.id
);
}
export function pokemonImage(number: number, kind: 'sprite' | 'shiny' | 'artwork' = 'sprite') {
return `${base}/game-art/pokemon/${kind}/${number}.webp`;
}

View File

@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest';
import { allowedOsuUrl, boundedInteger, firstCatches, validDate } from './journal';
import { gameRegions, milestonesFor, regionMaps } from './pokemon-world';
describe('journal input and collection rules', () => {
it('keeps the first registered catch even when a duplicate has an earlier caught date', () => {
const original = { id: 2, pokemonId: 387, nickname: 'Sprout', caughtOn: '2026-09-25' };
const duplicate = { id: 8, pokemonId: 387, nickname: 'Other', caughtOn: '2020-01-01' };
const input = [duplicate, original, { ...original, id: 9, pokemonId: 399 }];
expect(firstCatches(input).get(387)).toBe(original);
expect(firstCatches(input).size).toBe(2);
expect(input[0]).toBe(duplicate);
});
it('rejects malformed or impossible numeric values', () => {
for (const value of [null, '', '-1', '1e2', '1.5', ' 2', 'NaN', '101'])
expect(boundedInteger(value, 1, 100)).toBeNull();
expect(boundedInteger('100', 1, 100)).toBe(100);
expect(boundedInteger('0', 0, 100)).toBe(0);
});
it('accepts actual calendar dates including leap days', () => {
expect(validDate('2024-02-29')).toBe(true);
for (const date of [
'2026-02-29',
'2026-04-31',
'0000-01-01',
'2026-13-01',
'2026-09-25T00:00:00Z'
])
expect(validDate(date)).toBe(false);
});
it('accepts osu links and rejects lookalike hosts, credentials and unrelated routes', () => {
expect(allowedOsuUrl('https://osu.ppy.sh/beatmapsets/123#osu/456', 'beatmap')).toBe(true);
expect(allowedOsuUrl('https://osu.ppy.sh/scores/osu/123', 'score')).toBe(true);
for (const url of [
'javascript:alert(1)',
'http://osu.ppy.sh/beatmaps/1',
'https://osu.ppy.sh.evil.test/beatmaps/1',
'https://user@osu.ppy.sh/beatmaps/1',
'https://osu.ppy.sh:123/beatmaps/1',
'https://osu.ppy.sh/users/1'
])
expect(allowedOsuUrl(url, 'beatmap')).toBe(false);
});
});
describe('atlas and milestone integrity', () => {
it('has unique locations and finite map coordinates in every region', () => {
for (const map of Object.values(regionMaps)) {
expect(new Set(map.nodes.map((n) => n.slug)).size).toBe(map.nodes.length);
expect(map.source).toMatch(/^https:\/\//);
for (const node of map.nodes)
expect(Number.isFinite(node.x) && Number.isFinite(node.y)).toBe(true);
}
expect(regionMaps.sinnoh.nodes.some((n) => n.slug === 'sinnoh-route-201')).toBe(true);
});
it('uses the right region and version-specific leaders', () => {
expect(gameRegions('legends-arceus', ['sinnoh'])).toEqual(['hisui']);
expect(gameRegions('heartgold', ['kanto', 'johto'])).toEqual(['johto', 'kanto']);
expect(gameRegions('unknown', ['unknown'])).toEqual([]);
expect(
milestonesFor('platinum', 'sinnoh').filter((m) => m.category === 'badge')[2].title
).toContain('Fantina');
expect(
milestonesFor('diamond', 'sinnoh').filter((m) => m.category === 'badge')[2].title
).toContain('Maylene');
expect(milestonesFor('emerald', 'hoenn').some((m) => m.title === 'Champion · Wallace')).toBe(
true
);
expect(
milestonesFor('ultra-sun', 'alola').some((m) => m.title === 'Elite Four · Molayne')
).toBe(true);
expect(
milestonesFor('heartgold', 'johto').filter(
(m) => m.stage === 'postgame' && m.category === 'badge'
)
).toHaveLength(8);
});
});

View File

@@ -0,0 +1,67 @@
export const stages = [
{
value: 'story',
label: 'The adventure',
description: 'Catch as you go, earn badges and become Champion.'
},
{
value: 'postgame',
label: 'Beyond the credits',
description: 'Explore the postgame and finish what you can in this game.'
},
{
value: 'collection',
label: 'Complete the collection',
description: 'Trades, transfers and event Pokémon.'
}
] as const;
export type Stage = (typeof stages)[number]['value'];
export const obtainedMethods = [
'caught',
'starter',
'gift',
'evolved',
'bred',
'trade',
'transfer',
'event'
] as const;
export const isStage = (value: string): value is Stage => stages.some((s) => s.value === value);
export function boundedInteger(value: FormDataEntryValue | null, min: number, max: number) {
if (typeof value !== 'string' || !/^\d+$/.test(value)) return null;
const number = Number(value);
return Number.isSafeInteger(number) && number >= min && number <= max ? number : null;
}
export function validDate(value: string) {
return (
/^\d{4}-\d{2}-\d{2}$/.test(value) &&
Number(value.slice(0, 4)) > 0 &&
!Number.isNaN(Date.parse(value)) &&
new Date(value).toISOString().slice(0, 10) === value
);
}
export function firstCatches<T extends { id: number; pokemonId: number }>(catches: T[]) {
const first = new Map<number, T>();
for (const caught of [...catches].sort((a, b) => a.id - b.id))
if (!first.has(caught.pokemonId)) first.set(caught.pokemonId, caught);
return first;
}
export function allowedOsuUrl(value: string, kind: 'beatmap' | 'score') {
if (!value) return true;
try {
const url = new URL(value);
return (
url.protocol === 'https:' &&
url.hostname === 'osu.ppy.sh' &&
!url.username &&
!url.password &&
!url.port &&
(kind === 'beatmap'
? /^\/(beatmaps|b|beatmapsets)\/\d+\/?$/.test(url.pathname)
: /^\/scores\/(?:osu\/|taiko\/|fruits\/|mania\/)?\d+\/?$/.test(url.pathname))
);
} catch {
return false;
}
}

View File

@@ -30,6 +30,13 @@ export type MasteryRules = {
tiers: Tier[];
classes: string[];
steamApps: { id: number; label: string }[];
rankCaps?: {
player: { level: number; prestige: number };
weaponPrestige: number;
weaponLevelsByClass: Record<string, number>;
weaponLevelOverrides?: Record<string, number>;
sourceUrl?: string;
};
};
export type Weapon = { id: number; name: string; category: string; postLaunch: boolean };

View File

@@ -0,0 +1,305 @@
import atlas from '../../../data/pokemon-atlas.json';
export type AtlasNode = {
slug: string;
name: string;
x: number;
y: number;
kind: string;
cells: number[][];
};
export type RegionMap = {
name: string;
width: number;
height: number;
nodes: AtlasNode[];
source: string;
precision: string;
};
export const regionMaps: Record<string, RegionMap> = atlas.regions;
export function gameRegions(slug: string, regions: string[]) {
if (['gold', 'silver', 'crystal', 'heartgold', 'soulsilver'].includes(slug))
return ['johto', 'kanto'];
if (slug === 'legends-arceus') return ['hisui'];
if (slug.includes('teal-mask')) return ['kitakami'];
if (slug.includes('indigo-disk')) return ['blueberry'];
if (['colosseum', 'xd'].includes(slug)) return ['orre'];
return regions.filter((r) => r in regionMaps);
}
export function initialLocation(slug: string, region: string) {
if (['diamond', 'pearl'].includes(slug)) return 'lake-verity';
return (
(
{
sinnoh: 'sinnoh-route-201',
kanto: 'pallet-town',
johto: 'new-bark-town',
hoenn: 'hoenn-route-101',
unova: slug.endsWith('-2') ? 'aspertia-city' : 'nuvema-town',
kalos: 'aquacorde-town',
alola: 'alola-route-1',
galar: 'postwick',
hisui: 'obsidian-fieldlands',
paldea: 'cabo-poco',
orre: 'phenac-city',
kitakami: 'mossui-town',
blueberry: 'central-plaza'
} as Record<string, string>
)[region] ?? ''
);
}
export type Milestone = {
key: string;
title: string;
category: string;
stage: 'story' | 'postgame' | 'collection';
};
export function milestonesFor(slug: string, region: string): Milestone[] {
const rows: Milestone[] = [];
const add = (title: string, category: string, stage: Milestone['stage'] = 'story') =>
rows.push({
key: `${category}:${title.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`,
title,
category,
stage
});
const badges: Record<string, string[]> = {
kanto: [
'Boulder · Brock',
'Cascade · Misty',
'Thunder · Lt. Surge',
'Rainbow · Erika',
'Soul · Koga',
'Marsh · Sabrina',
'Volcano · Blaine',
'Earth · Giovanni'
],
johto: [
'Zephyr · Falkner',
'Hive · Bugsy',
'Plain · Whitney',
'Fog · Morty',
'Storm · Chuck',
'Mineral · Jasmine',
'Glacier · Pryce',
'Rising · Clair'
],
hoenn: [
'Stone · Roxanne',
'Knuckle · Brawly',
'Dynamo · Wattson',
'Heat · Flannery',
'Balance · Norman',
'Feather · Winona',
'Mind · Tate & Liza',
`Rain · ${slug === 'emerald' ? 'Juan' : 'Wallace'}`
],
sinnoh:
slug === 'platinum'
? [
'Coal · Roark',
'Forest · Gardenia',
'Relic · Fantina',
'Cobble · Maylene',
'Fen · Crasher Wake',
'Mine · Byron',
'Icicle · Candice',
'Beacon · Volkner'
]
: [
'Coal · Roark',
'Forest · Gardenia',
'Cobble · Maylene',
'Fen · Crasher Wake',
'Relic · Fantina',
'Mine · Byron',
'Icicle · Candice',
'Beacon · Volkner'
],
unova: slug.endsWith('-2')
? [
'Basic · Cheren',
'Toxic · Roxie',
'Insect · Burgh',
'Bolt · Elesa',
'Quake · Clay',
'Jet · Skyla',
'Legend · Drayden',
'Wave · Marlon'
]
: [
'Trio · Cilan / Chili / Cress',
'Basic · Lenora',
'Insect · Burgh',
'Bolt · Elesa',
'Quake · Clay',
'Jet · Skyla',
'Freeze · Brycen',
`Legend · ${slug === 'white' ? 'Iris' : 'Drayden'}`
],
kalos: [
'Bug · Viola',
'Cliff · Grant',
'Rumble · Korrina',
'Plant · Ramos',
'Voltage · Clemont',
'Fairy · Valerie',
'Psychic · Olympia',
'Iceberg · Wulfric'
],
galar: [
'Grass · Milo',
'Water · Nessa',
'Fire · Kabu',
slug === 'shield' ? 'Ghost · Allister' : 'Fighting · Bea',
'Fairy · Opal',
slug === 'shield' ? 'Ice · Melony' : 'Rock · Gordie',
'Dark · Piers',
'Dragon · Raihan'
],
paldea: [
'Bug · Katy',
'Grass · Brassius',
'Electric · Iono',
'Water · Kofu',
'Normal · Larry',
'Ghost · Ryme',
'Psychic · Tulip',
'Ice · Grusha'
]
};
if (
/legends-za|mega-dimension|champions|isle-of-armor|crown-tundra|teal-mask|indigo-disk/.test(
slug
)
) {
if (slug.includes('isle-of-armor'))
['Master dojo trials', 'Tower of Darkness / Waters', 'Final battle with Mustard'].forEach(
(t) => add(t, 'boss')
);
if (slug.includes('crown-tundra'))
[
'Calyrex expedition',
'Legendary giants expedition',
'Legendary birds expedition',
'Galarian Star Tournament'
].forEach((t) => add(t, 'event'));
if (slug.includes('teal-mask'))
['Festival of Masks', 'Loyal Three battles', 'Ogerpon', 'Final battle with Kieran'].forEach(
(t) => add(t, 'boss')
);
if (slug.includes('indigo-disk'))
['Crispin', 'Amarys', 'Lacey', 'Drayton', 'Kieran', 'Terapagos'].forEach((t) =>
add(t, 'boss')
);
} else {
(badges[region] ?? []).forEach((t) => add(t, 'badge'));
const leagues: Record<string, string[]> = {
kanto: ['Lorelei', 'Bruno', 'Agatha', 'Lance'],
johto: ['Will', 'Koga', 'Bruno', 'Karen'],
hoenn: ['Sidney', 'Phoebe', 'Glacia', 'Drake'],
sinnoh: ['Aaron', 'Bertha', 'Flint', 'Lucian'],
unova: ['Shauntal', 'Grimsley', 'Caitlin', 'Marshal'],
kalos: ['Malva', 'Siebold', 'Wikstrom', 'Drasna'],
alola: [slug.startsWith('ultra-') ? 'Molayne' : 'Hala', 'Olivia', 'Acerola', 'Kahili'],
paldea: ['Rika', 'Poppy', 'Larry', 'Hassel']
};
(leagues[region] ?? []).forEach((t) => add(`Elite Four · ${t}`, 'league'));
const champion: Record<string, string> = {
kanto: slug.startsWith('lets-go') ? 'Trace' : 'Blue',
johto: 'Lance',
hoenn: slug === 'emerald' ? 'Wallace' : 'Steven',
sinnoh: 'Cynthia',
unova: slug.endsWith('-2') ? 'Iris' : 'Alder',
kalos: 'Diantha',
galar: 'Leon',
paldea: 'Geeta'
};
if (champion[region])
add(
`Champion · ${champion[region]}`,
'league',
region === 'unova' && !slug.endsWith('-2') ? 'postgame' : 'story'
);
if (region === 'sinnoh') {
[
'Commander Mars · Valley Windworks',
'Commander Jupiter · Eterna',
'Team Galactic · Lake battles',
'Cyrus · Galactic HQ',
slug === 'platinum' ? 'Cyrus · Distortion World' : 'Cyrus · Spear Pillar'
].forEach((t) => add(t, 'boss'));
['National Pokédex', 'Battle Zone', 'Stark Mountain · Heatran', 'Battle Tower'].forEach((t) =>
add(t, 'event', 'postgame')
);
}
if (region === 'johto') {
['Brock', 'Misty', 'Lt. Surge', 'Erika', 'Janine', 'Sabrina', 'Blaine', 'Blue'].forEach((t) =>
add(`Kanto badge · ${t}`, 'badge', 'postgame')
);
add('Red · Mt. Silver', 'boss', 'postgame');
}
if (region === 'kanto') {
add('Team Rocket · Silph Co.', 'boss');
add('Cerulean Cave · Mewtwo', 'event', 'postgame');
}
if (region === 'hoenn') {
add('Team Aqua / Magma finale', 'boss');
add('Legendary weather Pokémon', 'event');
if (['omega-ruby', 'alpha-sapphire'].includes(slug))
add('Delta Episode', 'event', 'postgame');
}
if (region === 'unova') ['N', 'Ghetsis'].forEach((t) => add(`Team Plasma · ${t}`, 'boss'));
if (region === 'kalos') add('Lysandre · Team Flare finale', 'boss');
if (region === 'alola') {
[
'Verdant Cavern',
'Brooklet Hill',
'Wela Volcano Park',
'Lush Jungle',
'Hokulani Observatory',
'Thrifty Megamart',
'Vast Poni Canyon'
].forEach((t) => add(`Trial · ${t}`, 'badge'));
['Hala', 'Olivia', 'Nanu', 'Hapu'].forEach((t) => add(`Grand trial · ${t}`, 'boss'));
add(slug.startsWith('ultra-') ? 'Ultra Necrozma' : 'Lusamine', 'boss');
add(slug.startsWith('ultra-') ? 'Title match · Hau' : 'Title match · Kukui', 'league');
}
if (region === 'galar')
['Champion Cup semifinals', 'Champion Cup finals', 'Rose', 'Eternatus'].forEach((t) =>
add(t, 'boss')
);
if (region === 'hisui') {
['Kleavor', 'Lilligant', 'Arcanine', 'Electrode', 'Avalugg'].forEach((t) =>
add(`Noble · ${t}`, 'boss')
);
add('Dialga & Palkia', 'boss');
add('Volo & Giratina', 'boss', 'postgame');
add('Arceus', 'boss', 'postgame');
}
if (region === 'paldea') {
[
'Klawf',
'Bombirdier',
'Orthworm',
'Great Tusk / Iron Treads',
'Dondozo & Tatsugiri'
].forEach((t) => add(`Titan · ${t}`, 'boss'));
['Giacomo', 'Mela', 'Atticus', 'Ortega', 'Eri', 'Cassiopeia'].forEach((t) =>
add(`Team Star · ${t}`, 'boss')
);
['Arven', 'Nemona', 'The Way Home'].forEach((t) => add(t, 'boss'));
add('Academy Ace Tournament', 'event', 'postgame');
}
if (region === 'orre')
(slug === 'xd'
? ['Lovrina', 'Snattle', 'Gorigan', 'Greevil']
: ['Miror B.', 'Dakim', 'Venus', 'Ein', 'Nascour', 'Evice']
).forEach((t) => add(`Cipher · ${t}`, 'boss'));
}
add('Finish my in-game collection', 'collection', 'postgame');
['Trade evolutions', 'Version exclusives', 'Transfers from other games', 'Event Pokémon'].forEach(
(t) => add(t, 'collection', 'collection')
);
return rows;
}

View File

@@ -0,0 +1,108 @@
import { describe, it, expect } from 'vitest';
import { combinedProgress, fraction, challengeProgress, pokemonProgress } from './progress';
import { milestonesFor } from './pokemon-world';
const input: Parameters<typeof pokemonProgress>[0] = {
games: [{ id: 1, name: 'Platinum', slug: 'platinum', regions: ['sinnoh'] }],
entries: [
{ id: 1, gameId: 1, pokemonId: 387, availability: 'catchable' },
{ id: 2, gameId: 1, pokemonId: 399, availability: 'catchable' },
{ id: 3, gameId: 1, pokemonId: 1, availability: 'transfer_only' },
{ id: 4, gameId: 1, pokemonId: 99, availability: 'unknown' },
{ id: 5, gameId: 2, pokemonId: 387, availability: 'catchable' }
],
caughtIds: [],
seen: [],
journeys: [],
milestones: [],
dex: [
{ gameId: 1, pokemonId: 387 },
{ gameId: 1, pokemonId: 399 },
{ gameId: 1, pokemonId: 387 }
],
speciesTotal: 1025
};
describe('tracker completion calculations', () => {
it('balances categories, omits empty catalogs and clamps fractions', () => {
expect(
fraction(
combinedProgress([
{ value: 1, total: 2 },
{ value: 100, total: 100 },
{ value: 0, total: 0 }
])
)
).toBe(0.75);
expect(fraction({ value: 3, total: 2 })).toBe(1);
expect(fraction({ value: 0, total: 0 })).toBe(0);
expect(fraction(combinedProgress([]))).toBe(0);
});
it('credits partial challenge targets and explicit unlocks without overcounting', () => {
const result = challengeProgress(
[
{ id: 1, target: 100 },
{ id: 2, target: null },
{ id: 3, target: 5 }
],
[
{ challengeId: 1, current: 25, done: false },
{ challengeId: 2, current: 0, done: true },
{ challengeId: 3, current: 99, done: false }
]
);
expect(result).toEqual({ value: 2.25, total: 3 });
});
it('counts a species once globally, once per game, and caught implies seen', () => {
const result = pokemonProgress({
...input,
games: [...input.games, { id: 2, name: 'Diamond', slug: 'diamond', regions: ['sinnoh'] }],
caughtIds: [1, 5],
seen: [
{ gameId: 1, pokemonId: 387 },
{ gameId: 1, pokemonId: 399 }
]
});
expect(result.caught.value).toBe(1);
expect(result.seen.value).toBe(2);
expect(result.collection.value).toBe(2);
expect(result.games[0].regional).toEqual({ value: 1, total: 2 });
});
it('includes confirmed trade/transfer targets, excludes unknowns and scopes selected games', () => {
const result = pokemonProgress({
...input,
caughtIds: [1, 5],
seen: [{ gameId: 2, pokemonId: 999 }]
});
expect(result.games[0].collection).toEqual({ value: 1, total: 3 });
expect(result.games[0].inGame).toEqual({ value: 1, total: 2 });
expect(result.seen.value).toBe(1);
expect(pokemonProgress({ ...input, games: [] }).completed).toEqual({ value: 0, total: 0 });
});
it('allows real catches to confirm unknown availability', () => {
expect(pokemonProgress({ ...input, caughtIds: [4] }).games[0].collection).toEqual({
value: 1,
total: 4
});
});
it('credits story completion without pretending all collection tasks are complete', () => {
const before = pokemonProgress(input);
const after = pokemonProgress({ ...input, journeys: [{ gameId: 1, completed: true }] });
expect(after.completed.value).toBe(1);
expect(fraction(after.games[0].overall)).toBeGreaterThan(fraction(before.games[0].overall));
expect(fraction(after.games[0].overall)).toBeLessThan(1);
expect(after.games[0].badges.value).toBe(0);
});
it('infers a cleared game from all default story milestones, not arbitrary custom ones', () => {
const milestones = milestonesFor('platinum', 'sinnoh')
.filter((m) => m.stage === 'story')
.map((m) => ({ ...m, gameId: 1, done: true }));
expect(pokemonProgress({ ...input, milestones }).completed.value).toBe(1);
expect(
pokemonProgress({
...input,
milestones: [{ gameId: 1, key: 'custom:win', stage: 'story', category: 'boss', done: true }]
}).completed.value
).toBe(0);
});
});

View File

@@ -0,0 +1,125 @@
import { obtainableAvailability, type Availability } from '$lib/availability';
import { gameRegions, milestonesFor } from './pokemon-world';
export type ProgressCount = { value: number; total: number };
export const fraction = ({ value, total }: ProgressCount) =>
total > 0 ? Math.max(0, Math.min(1, value / total)) : 0;
export function combinedProgress(parts: ProgressCount[]): ProgressCount {
const available = parts.filter((p) => p.total > 0);
return { value: available.reduce((n, p) => n + fraction(p), 0), total: available.length };
}
export function sumProgress(parts: ProgressCount[]): ProgressCount {
return parts.reduce((a, p) => ({ value: a.value + p.value, total: a.total + p.total }), {
value: 0,
total: 0
});
}
export function challengeProgress(
challenges: { id: number; target: number | null }[],
progress: { challengeId: number; current: number; done: boolean }[]
) {
const saved = new Map(progress.map((p) => [p.challengeId, p]));
return {
value: challenges.reduce((n, c) => {
const p = saved.get(c.id);
return n + (p?.done ? 1 : fraction({ value: p?.current ?? 0, total: c.target ?? 0 }));
}, 0),
total: challenges.length
};
}
export function pokemonProgress(input: {
games: { id: number; name: string; slug: string | null; regions: string[] }[];
entries: { id: number; gameId: number; pokemonId: number; availability: Availability }[];
caughtIds: number[];
seen: { gameId: number; pokemonId: number }[];
journeys: { gameId: number; completed: boolean }[];
milestones: { gameId: number; key: string; stage: string; category: string; done: boolean }[];
dex: { gameId: number; pokemonId: number }[];
speciesTotal: number;
}) {
const caught = new Set(input.caughtIds);
const entries = Map.groupBy(input.entries, (e) => e.gameId);
const sightings = Map.groupBy(input.seen, (e) => e.gameId);
const milestones = Map.groupBy(input.milestones, (m) => m.gameId);
const dexes = Map.groupBy(input.dex, (d) => d.gameId);
const allCaught = new Set<number>();
const allSeen = new Set<number>();
const games = input.games.map((game) => {
const list = entries.get(game.id) ?? [];
const caughtSpecies = new Set(list.filter((e) => caught.has(e.id)).map((e) => e.pokemonId));
const seenSpecies = new Set([
...caughtSpecies,
...(sightings.get(game.id) ?? []).map((s) => s.pokemonId)
]);
for (const id of caughtSpecies) allCaught.add(id);
for (const id of seenSpecies) allSeen.add(id);
const collect = (ids: number[], source = caughtSpecies): ProgressCount => {
const unique = new Set(ids);
return { value: [...unique].filter((id) => source.has(id)).length, total: unique.size };
};
const collection = collect(
list
.filter((e) => e.availability !== 'unknown' || caughtSpecies.has(e.pokemonId))
.map((e) => e.pokemonId)
);
const inGame = collect(
list.filter((e) => obtainableAvailability.has(e.availability)).map((e) => e.pokemonId)
);
const seen = collect(
list.map((e) => e.pokemonId),
seenSpecies
);
const regionalIds = (dexes.get(game.id) ?? []).map((d) => d.pokemonId);
const regional = collect(regionalIds);
const regionalSeen = collect(regionalIds, seenSpecies);
const saved = new Map((milestones.get(game.id) ?? []).map((m) => [m.key, m]));
const defaults = milestonesFor(
game.slug ?? '',
gameRegions(game.slug ?? '', game.regions)[0] ?? ''
);
const count = (rows: { key: string }[]): ProgressCount => ({
value: rows.filter((m) => saved.get(m.key)?.done).length,
total: rows.length
});
const story = count(defaults.filter((m) => m.stage === 'story'));
const postgame = count(defaults.filter((m) => m.stage === 'postgame'));
const transfers = count(defaults.filter((m) => m.stage === 'collection'));
const badges = count(defaults.filter((m) => m.category === 'badge'));
const league = count(defaults.filter((m) => m.category === 'league'));
const bosses = count(defaults.filter((m) => m.category === 'boss'));
const custom = count([...saved.values()].filter((m) => m.key.startsWith('custom:')));
const completed =
input.journeys.some((j) => j.gameId === game.id && j.completed) ||
(story.total > 0 && story.value === story.total);
const adventure = { value: story.value + Number(completed), total: story.total + 1 };
return {
id: game.id,
name: game.name,
slug: game.slug,
collection,
inGame,
seen,
regional,
regionalSeen,
story,
adventure,
postgame,
transfers,
badges,
league,
bosses,
custom,
completed,
overall: combinedProgress([collection, adventure, postgame, transfers])
};
});
return {
games,
caught: { value: allCaught.size, total: input.speciesTotal },
seen: { value: allSeen.size, total: input.speciesTotal },
completed: { value: games.filter((g) => g.completed).length, total: games.length },
collection: sumProgress(games.map((g) => g.collection)),
overall: combinedProgress(games.map((g) => g.overall))
};
}

View File

@@ -0,0 +1,43 @@
import type { MasteryRules } from './mastery';
export type RankLimits = {
maxLevel: number | null;
maxPrestige: number;
canPrestigeOnlyAtMaxLevel: boolean;
rankable: boolean;
};
const capsFor = (rules: MasteryRules) => rules.rankCaps;
export function playerRankLimits(rules: MasteryRules): RankLimits {
const caps = capsFor(rules)?.player;
return {
maxLevel: caps?.level ?? null,
maxPrestige: caps?.prestige ?? 1000,
canPrestigeOnlyAtMaxLevel: false,
rankable: true
};
}
export function weaponRankLimits(
rules: MasteryRules,
weapon: Pick<{ category: string; name: string }, 'category' | 'name'>
): RankLimits {
const caps = capsFor(rules);
if (!caps) {
return {
maxLevel: null,
maxPrestige: 1000,
canPrestigeOnlyAtMaxLevel: false,
rankable: true
};
}
const maxLevel =
caps.weaponLevelOverrides?.[weapon.name] ?? caps.weaponLevelsByClass[weapon.category] ?? null;
return {
maxLevel,
maxPrestige: caps.weaponPrestige,
canPrestigeOnlyAtMaxLevel: true,
rankable: maxLevel !== null
};
}