add game tracker with owned pokemon data and call of duty mastery
All checks were successful
Test and deploy staging / staging (push) Successful in 18s

Fold catchemall into /projects/game-tracker (old URL redirects) and add
Call of Duty tracking for Black Ops through WWII.

- Pokémon catalog is now owned: locations, encounters and per-game
  evolution methods are built from the pinned PokéAPI snapshot and seeded.
- Pokémon you can evolve or breed in a game count as obtainable; mythicals
  without another route are marked event-only.
- Detail dialog with evolution chain and where to find it, location pages
  with admin notes and pictures, and admin corrections that survive reseeds.
- Call of Duty weapons, milestones and Steam achievements per game, with
  derived Diamond / Dark Matter / Solar / Black Sky / Chrome tiers.
- New migration (db:migrate:game-tracker), seeds (db:seed, db:seed:cod) and
  deploy preflight checks for them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-24 17:36:18 +00:00
parent 438623d857
commit a10585a4d2
49 changed files with 8236 additions and 170 deletions

View File

@@ -0,0 +1,116 @@
import { and, asc, countDistinct, eq, inArray, sql } from 'drizzle-orm';
import { db } from '$lib/server/db';
import {
catchEmAllSettings,
pokemonGameEntries,
trackerGames,
trackerItems,
trackerProgress,
userEntries,
userGames
} from '$lib/server/db/schema';
import { summarizeGame } from '$lib/game-tracker/mastery';
import type { PageServerLoad } from './$types';
const obtainable = inArray(pokemonGameEntries.availability, ['catchable', 'evolve', 'breed']);
async function pokemonSummary(userId: string | undefined) {
let gameIds: number[] | null = null;
if (userId) {
const [settings] = await db
.select({ id: catchEmAllSettings.id })
.from(catchEmAllSettings)
.where(eq(catchEmAllSettings.userId, userId));
const selected = await db
.select({ id: userGames.gameId })
.from(userGames)
.where(eq(userGames.userId, userId));
if (settings || selected.length) gameIds = selected.map((row) => row.id);
}
const scope =
gameIds === null ? obtainable : and(obtainable, inArray(pokemonGameEntries.gameId, gameIds));
const [totals] = await db
.select({
entries: sql<number>`count(*)`.mapWith(Number),
species: countDistinct(pokemonGameEntries.pokemonId),
games: countDistinct(pokemonGameEntries.gameId)
})
.from(pokemonGameEntries)
.where(scope);
const [caught] = userId
? await db
.select({
entries: sql<number>`count(*)`.mapWith(Number),
species: countDistinct(pokemonGameEntries.pokemonId)
})
.from(userEntries)
.innerJoin(pokemonGameEntries, eq(pokemonGameEntries.id, userEntries.pokemonGameEntryId))
.where(and(eq(userEntries.userId, userId), scope))
: [{ entries: 0, species: 0 }];
return { totals, caught };
}
async function callOfDutySummary(userId: string | undefined) {
const [gamesList, items, progress] = await Promise.all([
db
.select()
.from(trackerGames)
.where(eq(trackerGames.franchise, 'call-of-duty'))
.orderBy(asc(trackerGames.sortOrder)),
db
.select({
id: trackerItems.id,
gameId: trackerItems.gameId,
kind: trackerItems.kind,
name: trackerItems.name,
category: trackerItems.category,
postLaunch: trackerItems.postLaunch
})
.from(trackerItems),
userId
? db
.select({ itemId: trackerProgress.itemId, milestone: trackerProgress.milestone })
.from(trackerProgress)
.where(eq(trackerProgress.userId, userId))
: Promise.resolve([])
]);
const done = new Set(progress.map((row) => `${row.itemId}:${row.milestone}`));
const byGame = Map.groupBy(items, (item) => item.gameId);
return gamesList.map((game) => {
const list = byGame.get(game.id) ?? [];
const summary = summarizeGame(
game.rules,
list.filter((item) => item.kind === 'weapon'),
list.filter((item) => item.kind === 'achievement').map((item) => item.id),
(id, milestone) => done.has(`${id}:${milestone}`)
);
return {
slug: game.slug,
name: game.shortName,
releaseYear: game.releaseYear,
achievements: summary.achievements,
milestones: summary.milestones.map(({ key, label, value, total }) => ({
key,
label,
value,
total
})),
tiers: summary.tiers.map(({ key, label, scope, value, total, achieved }) => ({
key,
label,
scope,
value,
total,
achieved
}))
};
});
}
export const load: PageServerLoad = async ({ locals }) => {
const [pokemon, callOfDuty] = await Promise.all([
pokemonSummary(locals.user?.id),
callOfDutySummary(locals.user?.id)
]);
return { pokemon, callOfDuty };
};

View File

@@ -0,0 +1,101 @@
<script lang="ts">
import { resolve } from '$app/paths';
import ProgressBar from '$lib/components/ProgressBar.svelte';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
function percent(value: number, total: number) {
return total > 0 ? Math.round((value / total) * 100) : 0;
}
</script>
<div class="mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-14">
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">game tracker</h1>
<p class="mt-2 text-[var(--site-muted)]">
completionist progress: every Pokémon in every game, and every achievement, weapon and camo in
Call of Duty.
</p>
{#if !data.user}<p class="mt-6">
<a
href={resolve('/login?returnTo=/projects/game-tracker')}
class="underline underline-offset-4">sign in</a
> to save your progress.
</p>{/if}
<section class="mt-10">
<h2 class="text-2xl font-semibold">Pokémon</h2>
<a
href={resolve('/projects/game-tracker/pokemon')}
class="mt-4 block rounded-lg border border-[var(--site-border)] p-5 hover:bg-[var(--site-hover)]"
>
<div class="flex flex-wrap items-baseline justify-between gap-3">
<p class="text-xl font-semibold">catchemall</p>
<p class="text-xl font-semibold tabular-nums">
{percent(data.pokemon.caught.entries, data.pokemon.totals.entries)}%
</p>
</div>
<div class="mt-3">
<ProgressBar
value={data.pokemon.caught.entries}
max={data.pokemon.totals.entries}
label="Pokémon caught"
small
/>
</div>
<p class="mt-2 text-[var(--site-muted)]">
{data.pokemon.caught.entries} of {data.pokemon.totals.entries} obtainable entries ·
{data.pokemon.caught.species} of {data.pokemon.totals.species} species · {data.pokemon
.totals.games} games
</p>
</a>
<a
href={resolve('/projects/game-tracker/pokemon/locations')}
class="mt-3 inline-block underline underline-offset-4">browse Pokémon locations</a
>
</section>
<section class="mt-12">
<h2 class="text-2xl font-semibold">Call of Duty</h2>
<ul class="mt-4 grid gap-4 md:grid-cols-2">
{#each data.callOfDuty as game (game.slug)}
<li>
<a
href={resolve('/projects/game-tracker/call-of-duty/[slug]', { slug: game.slug })}
class="block h-full rounded-lg border border-[var(--site-border)] p-5 hover:bg-[var(--site-hover)]"
>
<div class="flex flex-wrap items-baseline justify-between gap-3">
<p class="text-xl font-semibold">{game.name}</p>
<p class="text-[var(--site-muted)]">{game.releaseYear}</p>
</div>
<dl class="mt-4 grid gap-3">
{#each [{ key: 'achievements', label: 'achievements', ...game.achievements }, ...game.milestones] as row (row.key)}
<div>
<div class="flex justify-between gap-3 text-[var(--site-muted)]">
<dt>{row.label}</dt>
<dd class="tabular-nums">{row.value}/{row.total}</dd>
</div>
<div class="mt-1">
<ProgressBar value={row.value} max={row.total} label={row.label} small />
</div>
</div>
{/each}
</dl>
{#if game.tiers.length}
<p class="mt-4 text-[var(--site-muted)]">
{game.tiers
.map((tier) =>
tier.scope === 'class'
? `${tier.label} ${tier.value}/${tier.total} classes`
: `${tier.label} ${tier.achieved ? '✓' : `${percent(tier.value, tier.total)}%`}`
)
.join(' · ')}
</p>
{/if}
</a>
</li>
{:else}
<li class="text-[var(--site-muted)]">the Call of Duty catalog has not been loaded yet.</li>
{/each}
</ul>
</section>
</div>

View File

@@ -0,0 +1,99 @@
import { error, fail } from '@sveltejs/kit';
import { and, asc, eq } from 'drizzle-orm';
import { db } from '$lib/server/db';
import { trackerGames, trackerItems, trackerProgress } from '$lib/server/db/schema';
import { appliesTo } from '$lib/game-tracker/mastery';
import type { Actions, PageServerLoad } from './$types';
async function findGame(slug: string) {
const [game] = await db
.select()
.from(trackerGames)
.where(and(eq(trackerGames.slug, slug), eq(trackerGames.franchise, 'call-of-duty')));
if (!game) error(404, 'Game not found');
return game;
}
export const load: PageServerLoad = async ({ params, locals }) => {
const game = await findGame(params.slug);
const items = await db
.select({
id: trackerItems.id,
kind: trackerItems.kind,
category: trackerItems.category,
name: trackerItems.name,
description: trackerItems.description,
icon: trackerItems.icon,
postLaunch: trackerItems.postLaunch
})
.from(trackerItems)
.where(eq(trackerItems.gameId, game.id))
.orderBy(asc(trackerItems.kind), asc(trackerItems.sortOrder));
const progress = locals.user
? await db
.select({ itemId: trackerProgress.itemId, milestone: trackerProgress.milestone })
.from(trackerProgress)
.innerJoin(trackerItems, eq(trackerItems.id, trackerProgress.itemId))
.where(and(eq(trackerProgress.userId, locals.user.id), eq(trackerItems.gameId, game.id)))
: [];
return {
game: { slug: game.slug, name: game.name, shortName: game.shortName, rules: game.rules },
items,
done: progress.map((row) => `${row.itemId}:${row.milestone}`),
seo: {
title: `${game.shortName} · Game Tracker`,
description: `Track ${game.name} achievements, weapon levels and camo mastery.`
}
};
};
export const actions = {
toggle: async ({ request, locals, params }) => {
if (!locals.user) return fail(401, { message: 'Log in to track your progress' });
const game = await findGame(params.slug);
const data = await request.formData();
const itemId = Number(data.get('itemId'));
const milestone = String(data.get('milestone') ?? '');
if (!Number.isSafeInteger(itemId) || itemId <= 0 || itemId > 2147483647)
return fail(400, { message: 'Invalid item' });
const [item] = await db
.select()
.from(trackerItems)
.where(and(eq(trackerItems.id, itemId), eq(trackerItems.gameId, game.id)));
if (!item) return fail(404, { message: 'Unknown item' });
const valid =
item.kind === 'achievement'
? milestone === 'done'
: game.rules.milestones.some(
(rule) =>
rule.key === milestone &&
appliesTo(rule, {
id: item.id,
name: item.name,
category: item.category,
postLaunch: item.postLaunch
})
);
if (!valid) return fail(400, { message: 'That milestone does not apply here' });
if (data.has('done') && data.get('done') !== 'on')
return fail(400, { message: 'Invalid state' });
if (data.get('done') === 'on') {
await db
.insert(trackerProgress)
.values({ userId: locals.user.id, itemId, milestone })
.onConflictDoNothing();
} else {
await db
.delete(trackerProgress)
.where(
and(
eq(trackerProgress.userId, locals.user.id),
eq(trackerProgress.itemId, itemId),
eq(trackerProgress.milestone, milestone)
)
);
}
return { saved: true };
}
} satisfies Actions;

View File

@@ -0,0 +1,284 @@
<script lang="ts">
import { onMount } from 'svelte';
import { resolve } from '$app/paths';
import { deserialize } from '$app/forms';
import { SvelteSet } from 'svelte/reactivity';
import ProgressBar from '$lib/components/ProgressBar.svelte';
import { appliesTo, summarizeGame } from '$lib/game-tracker/mastery';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
// rebuilt whenever the page data changes, so the server render already shows progress
const done = $derived(new SvelteSet(data.done));
const pending = new SvelteSet<string>();
let saveError = $state('');
// checkboxes stay disabled until hydration, so early clicks can't silently go unsaved
let hydrated = $state(false);
onMount(() => (hydrated = true));
const isDone = (id: number, milestone: string) => done.has(`${id}:${milestone}`);
async function toggle(itemId: number, milestone: string, checked: boolean) {
const key = `${itemId}:${milestone}`;
if (pending.has(key)) return;
pending.add(key);
saveError = '';
if (checked) done.add(key);
else done.delete(key);
const body = new FormData();
body.set('itemId', String(itemId));
body.set('milestone', milestone);
if (checked) body.set('done', 'on');
let failed: boolean;
try {
const response = await fetch('?/toggle', { method: 'POST', body });
failed = deserialize(await response.text()).type !== 'success';
} catch {
failed = true;
}
pending.delete(key);
if (failed) {
saveError = 'Could not save your progress. Please try again, or log in again.';
if (checked) done.delete(key);
else done.add(key);
}
}
const rules = $derived(data.game.rules);
const weapons = $derived(data.items.filter((item) => item.kind === 'weapon'));
const achievements = $derived(data.items.filter((item) => item.kind === 'achievement'));
const summary = $derived(
summarizeGame(
rules,
weapons,
achievements.map((item) => item.id),
isDone
)
);
type Bar = {
label: string;
description?: string;
value: number;
total: number;
achieved: boolean;
};
const bars = $derived<Bar[]>([
{ label: 'achievements', ...summary.achievements },
...summary.milestones,
...summary.tiers.map((tier) => ({
...tier,
label: tier.scope === 'class' ? `${tier.label} classes` : tier.label
}))
]);
const classes = $derived(
rules.classes
.map((name) => {
const list = weapons.filter((weapon) => weapon.category === name);
const milestones = rules.milestones.filter((milestone) =>
list.some((weapon) => appliesTo(milestone, weapon))
);
const tiers = rules.tiers
.filter((tier) => tier.scope === 'class')
.map((tier) => ({ tier, count: summary.mastery.classTiers.get(tier.key)?.get(name) }))
.filter((entry) => entry.count);
return { name, list, milestones, tiers };
})
.filter((entry) => entry.list.length)
);
const categories = $derived([...Map.groupBy(achievements, (item) => item.category)]);
const tabs = ['weapons', 'achievements'] as const;
let tab = $state<(typeof tabs)[number]>('weapons');
let hideDone = $state(false);
function percent(value: number, total: number) {
return total > 0 ? Math.round((value / total) * 100) : 0;
}
</script>
<div class="mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-14">
<a
href={resolve('/projects/game-tracker')}
class="text-[var(--site-muted)] hover:text-[var(--site-text)]">← game tracker</a
>
<h1 class="mt-3 text-4xl font-semibold tracking-tight sm:text-5xl">{data.game.shortName}</h1>
<p class="mt-2 text-[var(--site-muted)]">
{weapons.length} weapons · {achievements.length} achievements
</p>
{#if !data.user}<p class="mt-6">
<a
href={resolve(`/login?returnTo=/projects/game-tracker/call-of-duty/${data.game.slug}`)}
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}
<div
class="mt-8 grid gap-x-10 gap-y-7 border-y border-[var(--site-border)] py-6 sm:grid-cols-2 lg:grid-cols-3"
>
{#each bars as bar (bar.label)}
<div class="min-w-0" title={bar.description}>
<div class="flex flex-wrap items-baseline justify-between gap-2">
<p class="font-medium">{bar.label}</p>
<p class="text-xl font-semibold tabular-nums">
{bar.achieved ? '✓' : `${percent(bar.value, bar.total)}%`}
</p>
</div>
<div class="mt-3">
<ProgressBar value={bar.value} max={bar.total} label={bar.label} small />
</div>
<p class="mt-2 text-[var(--site-muted)]">{bar.value} of {bar.total}</p>
</div>
{/each}
</div>
<div class="mt-8 flex flex-wrap items-center justify-between gap-4">
<div role="tablist" class="flex gap-6 text-lg">
{#each tabs as name (name)}
<button
role="tab"
type="button"
aria-selected={tab === name}
onclick={() => (tab = name)}
class={tab === name
? 'underline underline-offset-8'
: 'text-[var(--site-muted)] hover:text-[var(--site-text)]'}>{name}</button
>
{/each}
</div>
<label class="flex cursor-pointer items-center gap-3">
<input type="checkbox" bind:checked={hideDone} class="h-5 w-5 accent-[var(--site-text)]" />
hide finished
</label>
</div>
{#if tab === 'weapons'}
{#if rules.tiers.length || rules.milestones.length}
<p class="mt-4 max-w-3xl text-[var(--site-muted)]">
{[...rules.milestones, ...rules.tiers]
.filter((rule) => rule.description)
.map((rule) => `${rule.label}: ${rule.description}`)
.join('. ')}. DLC weapons don’t count toward class or overall camos.
</p>
{/if}
{#each classes as group (group.name)}
{@const rows = group.list.filter(
(weapon) =>
!hideDone ||
group.milestones.some(
(milestone) => appliesTo(milestone, weapon) && !isDone(weapon.id, milestone.key)
)
)}
<section class="mt-10">
<div class="flex flex-wrap items-baseline justify-between gap-3">
<h2 class="text-2xl font-semibold">{group.name}</h2>
<p class="text-[var(--site-muted)]">
{#each group.tiers as { tier, count } (tier.key)}
<span class={count?.achieved ? 'ml-4 text-[var(--site-text)]' : 'ml-4'}
>{tier.label}
{count?.achieved ? '✓' : `${count?.value}/${count?.total}`}</span
>
{/each}
</p>
</div>
{#if rows.length}
<table class="mt-3 w-full border-collapse">
<thead>
<tr class="border-b border-[var(--site-border)] text-left text-[var(--site-muted)]">
<th scope="col" class="py-2 font-medium">weapon</th>
{#each group.milestones as milestone (milestone.key)}<th
scope="col"
class="w-20 py-2 text-center font-medium sm:w-28"
title={milestone.description}>{milestone.label}</th
>{/each}
</tr>
</thead>
<tbody>
{#each rows as weapon (weapon.id)}
{@const tiers = summary.mastery.weaponTiers(weapon)}
<tr class="border-b border-[var(--site-border)]">
<th scope="row" class="py-2 text-left font-normal">
{weapon.name}
{#if weapon.postLaunch}<span class="ml-1 text-sm text-[var(--site-muted)]"
>DLC</span
>{/if}
{#if tiers.length}<span class="block text-sm text-[var(--site-muted)]"
>{tiers.map((tier) => tier.label).join(' · ')}</span
>{/if}
</th>
{#each group.milestones as milestone (milestone.key)}
<td class="py-2 text-center">
{#if appliesTo(milestone, weapon)}
<input
type="checkbox"
class="h-5 w-5 cursor-pointer accent-[var(--site-text)]"
checked={isDone(weapon.id, milestone.key)}
disabled={!hydrated ||
!data.user ||
pending.has(`${weapon.id}:${milestone.key}`)}
aria-label={`${weapon.name}: ${milestone.label}`}
title={data.user
? milestone.description
: 'Sign in to save your progress'}
onchange={(event) =>
toggle(weapon.id, milestone.key, event.currentTarget.checked)}
/>
{:else}<span class="text-[var(--site-muted)]">—</span>{/if}
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
{:else}
<p class="mt-3 text-[var(--site-muted)]">all done.</p>
{/if}
</section>
{/each}
{:else}
{#each categories as [category, list] (category)}
{@const rows = list.filter((item) => !hideDone || !isDone(item.id, 'done'))}
<section class="mt-10">
<div class="flex flex-wrap items-baseline justify-between gap-3">
<h2 class="text-2xl font-semibold">{category}</h2>
<p class="text-[var(--site-muted)]">
{list.filter((item) => isDone(item.id, 'done')).length} of {list.length}
</p>
</div>
<ul class="mt-3">
{#each rows as item (item.id)}
<li class="border-t border-[var(--site-border)]">
<label class="flex cursor-pointer items-center gap-4 py-3">
{#if item.icon}<img
src={item.icon}
alt=""
loading="lazy"
width="48"
height="48"
class="h-12 w-12 shrink-0 rounded"
class:opacity-40={!isDone(item.id, 'done')}
/>{/if}
<span class="min-w-0 flex-1">
<span class="block font-medium">{item.name}</span>
{#if item.description}<span class="block text-[var(--site-muted)]"
>{item.description}</span
>{/if}
</span>
<input
type="checkbox"
class="h-5 w-5 shrink-0 cursor-pointer accent-[var(--site-text)]"
checked={isDone(item.id, 'done')}
disabled={!hydrated || !data.user || pending.has(`${item.id}:done`)}
aria-label={`Unlocked ${item.name}`}
onchange={(event) => toggle(item.id, 'done', event.currentTarget.checked)}
/>
</label>
</li>
{:else}
<li class="border-t border-[var(--site-border)] py-3 text-[var(--site-muted)]">
all unlocked.
</li>
{/each}
</ul>
</section>
{/each}
{/if}
</div>

View File

@@ -0,0 +1,184 @@
import { db } from '$lib/server/db';
import {
games,
pokemon as pokemonTable,
pokemonGameEntries,
userEntries,
userGames,
catchEmAllSettings
} from '$lib/server/db/schema';
import { and, asc, eq, inArray } from 'drizzle-orm';
import type { Actions, PageServerLoad } from './$types';
import { availabilityValues, obtainableAvailability, type Availability } from '$lib/availability';
import { requireAdmin } from '$lib/server/authorization';
import { fail } from '@sveltejs/kit';
export type WireEntry = [id: number, gameId: number, availability: number, note?: string];
export const load: PageServerLoad = async ({ locals }) => {
const [pokemonRows, entryRows, gameRows] = await Promise.all([
db
.select({
id: pokemonTable.id,
number: pokemonTable.number,
name: pokemonTable.name
})
.from(pokemonTable)
.orderBy(asc(pokemonTable.number)),
db
.select({
id: pokemonGameEntries.id,
pokemonId: pokemonGameEntries.pokemonId,
gameId: pokemonGameEntries.gameId,
availability: pokemonGameEntries.availability,
note: pokemonGameEntries.note
})
.from(pokemonGameEntries),
db
.select({ id: games.id, name: games.name, generation: games.generation })
.from(games)
.orderBy(asc(games.generation), asc(games.id))
]);
const entries = Map.groupBy(entryRows, (entry) => entry.pokemonId);
const pokemon = pokemonRows.map((species) => ({
...species,
entries: (entries.get(species.id) ?? []).map((e): WireEntry => {
const code = availabilityValues.indexOf(e.availability);
return e.note === null ? [e.id, e.gameId, code] : [e.id, e.gameId, code, e.note];
})
}));
let caughtIds: number[] = [];
let selectedGameIds: number[] = [];
let gamesConfigured = false;
if (locals.user) {
const [settings] = await db
.select()
.from(catchEmAllSettings)
.where(eq(catchEmAllSettings.userId, locals.user.id));
gamesConfigured = !!settings;
const caughtRows = await db
.select({ id: userEntries.pokemonGameEntryId })
.from(userEntries)
.where(eq(userEntries.userId, locals.user.id));
caughtIds = caughtRows.map((r) => r.id);
const gameRows = await db
.select({ id: userGames.gameId })
.from(userGames)
.where(eq(userGames.userId, locals.user.id));
selectedGameIds = gameRows.map((r) => r.id);
}
return {
pokemon,
games: gameRows,
caughtIds,
selectedGameIds,
gamesConfigured: gamesConfigured || selectedGameIds.length > 0
};
};
function entryId(data: FormData) {
const id = Number(data.get('entryId'));
return Number.isSafeInteger(id) && id > 0 && id <= 2147483647 ? id : null;
}
export const actions = {
togglePokemonCaught: async ({ request, locals }) => {
if (!locals.user) return fail(401, { message: 'Log in to track your progress' });
const data = await request.formData();
const id = entryId(data);
if (!id) return fail(400, { message: 'Invalid entry' });
// only obtainable entries can be toggled — the client disables the others
// but we can't trust that
const [entry] = await db
.select({ availability: pokemonGameEntries.availability })
.from(pokemonGameEntries)
.where(eq(pokemonGameEntries.id, id));
if (!entry) return fail(404, { message: 'Unknown entry' });
if (!obtainableAvailability.has(entry.availability)) {
return fail(400, { message: 'This Pokémon has no confirmed way to obtain it in this game' });
}
if (data.has('caught') && data.get('caught') !== 'on')
return fail(400, { message: 'Invalid caught state' });
const caught = data.get('caught') === 'on';
if (caught) {
await db
.insert(userEntries)
.values({ pokemonGameEntryId: id, userId: locals.user.id })
.onConflictDoNothing();
} else {
await db
.delete(userEntries)
.where(and(eq(userEntries.pokemonGameEntryId, id), eq(userEntries.userId, locals.user.id)));
}
return { saved: true };
},
setGames: async ({ request, locals }) => {
if (!locals.user) return fail(401, { message: 'Log in to set rules' });
const data = await request.formData();
const submittedIds = data.getAll('gameIds').map(Number);
if (
submittedIds.length > 200 ||
submittedIds.some((id) => !Number.isSafeInteger(id) || id <= 0)
) {
return fail(400, { message: 'Invalid game selection' });
}
const gameIds = [...new Set(submittedIds)];
if (gameIds.length > 0) {
const validGames = await db
.select({ id: games.id })
.from(games)
.where(inArray(games.id, gameIds));
if (validGames.length !== gameIds.length) return fail(400, { message: 'Unknown game' });
}
const userId = locals.user.id;
await db.transaction(async (tx) => {
await tx
.insert(catchEmAllSettings)
.values({ userId })
.onConflictDoUpdate({ target: catchEmAllSettings.userId, set: { userId } });
await tx.delete(userGames).where(eq(userGames.userId, userId));
if (gameIds.length > 0) {
await tx.insert(userGames).values(gameIds.map((gameId) => ({ userId, gameId })));
}
});
return { saved: true };
},
// Admin correction of how a Pokémon is obtained in one game. Manual entries
// survive reseeding; "reset" hands the entry back to the catalog.
saveEntry: async (event) => {
requireAdmin(event);
const data = await event.request.formData();
const id = entryId(data);
if (!id) return fail(400, { message: 'Invalid entry' });
if (data.get('reset') === 'on') {
await db
.update(pokemonGameEntries)
.set({ source: 'pokeapi' })
.where(eq(pokemonGameEntries.id, id));
return { saved: true, message: 'The catalog will restore this entry on the next seed.' };
}
const availability = String(data.get('availability'));
if (!(availabilityValues as readonly string[]).includes(availability))
return fail(400, { message: 'Invalid availability' });
const note = String(data.get('note') ?? '').trim();
if (note.length > 500) return fail(400, { message: 'Keep the note under 500 characters' });
const [updated] = await db
.update(pokemonGameEntries)
.set({ availability: availability as Availability, note: note || null, source: 'manual' })
.where(eq(pokemonGameEntries.id, id))
.returning({ id: pokemonGameEntries.id });
if (!updated) return fail(404, { message: 'Unknown entry' });
return { saved: true };
}
} satisfies Actions;

View File

@@ -0,0 +1,475 @@
<script lang="ts">
import { onMount } from 'svelte';
import { resolve } from '$app/paths';
import { deserialize, enhance } from '$app/forms';
import { invalidateAll } from '$app/navigation';
import {
availabilityLabels,
availabilityValues,
obtainableAvailability,
type Availability
} from '$lib/availability';
import { SvelteSet } from 'svelte/reactivity';
import type { PageProps } from './$types';
import ProgressBar from '$lib/components/ProgressBar.svelte';
import PokemonDetails from '$lib/catch-em-all/PokemonDetails.svelte';
import { summarizeProgress } from '$lib/catch-em-all/progress';
type Entry = { id: number; availability: Availability; note: string | null };
type Game = { id: number; name: string };
let { data, form }: PageProps = $props();
// rebuilt whenever the page data changes, so the server render already shows progress
const caught = $derived(new SvelteSet(data.caughtIds));
const pending = new SvelteSet<number>();
let saveError = $state('');
// checkboxes stay disabled until hydration, so early clicks can't silently go unsaved
let hydrated = $state(false);
onMount(() => (hydrated = true));
async function toggleCaught(entryId: number, checked: boolean) {
if (pending.has(entryId)) return;
pending.add(entryId);
saveError = '';
if (checked) caught.add(entryId);
else caught.delete(entryId);
const body = new FormData();
body.set('entryId', String(entryId));
if (checked) body.set('caught', 'on');
let failed: boolean;
try {
const response = await fetch('?/togglePokemonCaught', { method: 'POST', body });
const result = deserialize(await response.text());
failed = result.type !== 'success';
} catch {
failed = true;
}
pending.delete(entryId);
if (failed) {
saveError = 'Could not save your catch. Please try again, or log in again.';
if (checked) caught.delete(entryId);
else caught.add(entryId);
}
}
const entryMap = $derived(
new Map(
data.pokemon.map((pokemon) => [
pokemon.id,
new Map(
pokemon.entries.map(([id, gameId, code, note]) => [
gameId,
{ id, availability: availabilityValues[code], note: note ?? null } satisfies Entry
])
)
])
)
);
const selectedGameIds = $derived(new Set(data.selectedGameIds));
const visibleGames = $derived(
data.gamesConfigured ? data.games.filter((game) => selectedGameIds.has(game.id)) : data.games
);
let viewGameId = $state<number | null>(null);
$effect(() => {
if (!visibleGames.some((game) => game.id === viewGameId))
viewGameId = visibleGames[0]?.id ?? null;
});
const viewGame = $derived(visibleGames.find((game) => game.id === viewGameId));
const visibleGameIds = $derived(new Set(visibleGames.map((game) => game.id)));
const progress = $derived(summarizeProgress(data.pokemon, visibleGameIds, caught));
const progressBars = $derived([
{
label: 'seen at least once',
value: progress.seenPokemon,
total: progress.catchablePokemon,
unit: 'Pokémon'
},
{
label: 'caught in every game it’s in',
value: progress.completedPokemon,
total: progress.catchablePokemon,
unit: 'Pokémon'
},
{
label: 'games completed',
value: progress.completedGames,
total: progress.catchableGames,
unit: 'games'
}
]);
function percent(value: number, total: number) {
return total > 0 ? ((value / total) * 100).toFixed(1) : '0.0';
}
let search = $state('');
let showUnobtainable = $state(false);
let pageNumber = $state(1);
const pageSize = 25;
const filteredPokemon = $derived(
data.pokemon.filter((pokemon) => {
const availability =
viewGameId === null ? undefined : entryMap.get(pokemon.id)?.get(viewGameId)?.availability;
if (
!showUnobtainable &&
viewGameId !== null &&
!(availability && obtainableAvailability.has(availability))
)
return false;
return `${pokemon.number} ${pokemon.name}`
.toLowerCase()
.includes(search.trim().toLowerCase());
})
);
const pageCount = $derived(Math.max(1, Math.ceil(filteredPokemon.length / pageSize)));
$effect(() => {
if (pageNumber > pageCount) pageNumber = pageCount;
});
const visiblePokemon = $derived(
filteredPokemon.slice((pageNumber - 1) * pageSize, pageNumber * pageSize)
);
const gamesByGeneration = $derived(
[...Map.groupBy(data.games, (game) => game.generation)].sort(([a], [b]) => a - b)
);
let rulesDialog: HTMLDialogElement;
const draft = new SvelteSet<number>();
function openRules() {
draft.clear();
for (const id of data.gamesConfigured ? selectedGameIds : data.games.map((game) => game.id))
draft.add(id);
rulesDialog.showModal();
}
function tooltip(entry: Entry | undefined, game: Game): string | null {
if (!entry) return `No catalog record for ${game.name}; availability is not confirmed`;
if (obtainableAvailability.has(entry.availability)) return null;
const reason = {
trade_only: 'Only obtainable by trading in this game',
transfer_only: 'Only obtainable by transferring from another game',
event_only: 'Only obtainable through an event distribution',
unknown: 'Availability has not been confirmed'
}[entry.availability as 'trade_only' | 'transfer_only' | 'event_only' | 'unknown'];
return entry.note ? `${reason} (${entry.note})` : reason;
}
let detailsDialog: HTMLDialogElement;
let detailNumber = $state<number | null>(null);
function openDetails(number: number) {
detailNumber = number;
detailsDialog.showModal();
}
</script>
<div class="mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-14">
<a
href={resolve('/projects/game-tracker')}
class="text-[var(--site-muted)] hover:text-[var(--site-text)]">← game tracker</a
>
<div class="mt-3 flex flex-wrap items-end justify-between gap-5">
<div>
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">catchemall</h1>
<p class="mt-2 text-[var(--site-muted)]">
{data.pokemon.length} Pokémon · {data.games.length} games
</p>
</div>
<button
type="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/game-tracker/pokemon')}
class="underline underline-offset-4">sign in</a
> to save your progress.
</p>{/if}
{#if saveError}<p role="alert" class="mt-5 text-red-500">{saveError}</p>{/if}
{#if !data.pokemon.length}<p class="mt-8 text-[var(--site-muted)]">
the Pokémon catalog has not been loaded yet.
</p>{/if}
<div class="mt-8 border-y border-[var(--site-border)] py-6">
<div class="flex flex-wrap items-baseline justify-between gap-2">
<p class="font-medium">overall progress</p>
<p class="text-2xl font-semibold tabular-nums">
{percent(progress.caughtEntries, progress.catchableEntries)}%
</p>
</div>
<div class="mt-3">
<ProgressBar
value={progress.caughtEntries}
max={progress.catchableEntries}
label="Overall catch progress"
/>
</div>
<p class="mt-2 text-base text-[var(--site-muted)]">
{progress.caughtEntries} of {progress.catchableEntries} obtainable entries caught
</p>
<div class="mt-8 grid gap-x-10 gap-y-7 lg:grid-cols-3">
{#each progressBars as item (item.label)}
<div class="min-w-0">
<div class="flex flex-wrap items-baseline justify-between gap-2">
<p class="font-medium">{item.label}</p>
<p class="text-xl font-semibold tabular-nums">{percent(item.value, item.total)}%</p>
</div>
<div class="mt-3">
<ProgressBar value={item.value} max={item.total} label={item.label} small />
</div>
<p class="mt-2 text-base text-[var(--site-muted)]">
{item.value} of {item.total}
{item.unit}
</p>
</div>
{/each}
</div>
<p class="mt-6 text-base text-[var(--site-muted)]">
counts use your selected games. Pokémon you can evolve or breed there count as obtainable.
</p>
</div>
<div class="mt-8 grid gap-4 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<label class="block"
>search Pokémon
<input
type="search"
bind:value={search}
oninput={() => (pageNumber = 1)}
placeholder="name or number"
class="mt-2 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}
onchange={() => (pageNumber = 1)}
disabled={!visibleGames.length}
class="mt-2 block w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-3"
>
{#each visibleGames as game (game.id)}<option value={game.id}>{game.name}</option>{/each}
</select>
</label>
</div>
<div class="mt-5 flex flex-wrap items-center justify-between gap-4">
<label class="flex cursor-pointer items-center gap-3">
<input
type="checkbox"
checked={showUnobtainable}
onchange={(event) => {
showUnobtainable = event.currentTarget.checked;
pageNumber = 1;
}}
class="h-5 w-5 accent-[var(--site-text)]"
/>
show Pokémon you can’t get in this game
</label>
<a
href={resolve('/projects/game-tracker/pokemon/locations')}
class="underline underline-offset-4">browse locations</a
>
</div>
{#if viewGame}
<div class="mt-8">
<table class="w-full table-fixed border-collapse">
<colgroup><col /><col class="w-24 sm:w-36" /></colgroup>
<thead
><tr class="border-b border-[var(--site-border)] text-left text-[var(--site-muted)]"
><th scope="col" class="py-3 font-medium">Pokémon</th><th
scope="col"
class="py-3 text-center font-medium">caught</th
></tr
></thead
>
<tbody>
{#each visiblePokemon as pokemon (pokemon.id)}
{@const entry = entryMap.get(pokemon.id)?.get(viewGame.id)}
{@const reason = tooltip(entry, viewGame)}
<tr class="border-b border-[var(--site-border)]">
<th scope="row" class="py-2 text-left font-normal">
<button
type="button"
onclick={() => openDetails(pokemon.number)}
class="flex w-full min-w-0 items-center gap-3 text-left hover:text-[var(--site-muted)]"
>
<img
src="https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/{pokemon.number}.png"
alt=""
loading="lazy"
width="40"
height="40"
class="h-10 w-10 shrink-0"
/>
<span class="min-w-0"
><span class="mr-2 text-[var(--site-muted)]">#{pokemon.number}</span
>{pokemon.name}
{#if entry && reason === null}<span
class="block truncate text-sm text-[var(--site-muted)]"
>{availabilityLabels[entry.availability]}{entry.note
? ` · ${entry.note}`
: ''}</span
>{/if}</span
>
</button>
</th>
<td class="py-2 text-center">
{#if entry && reason === null}
<input
type="checkbox"
class="h-5 w-5 cursor-pointer accent-[var(--site-text)]"
checked={caught.has(entry.id)}
disabled={!hydrated || !data.user || pending.has(entry.id)}
aria-label={`Caught ${pokemon.name} in ${viewGame.name}`}
title={data.user ? (entry.note ?? undefined) : 'Sign in to save your progress'}
onchange={(event) => toggleCaught(entry.id, event.currentTarget.checked)}
/>
{:else}
<span
title={reason ?? undefined}
aria-label={`${pokemon.name} in ${viewGame.name}: ${reason}`}
class="text-[var(--site-muted)]"
>{!entry ? '—' : entry.availability === 'unknown' ? '?' : '·'}</span
>
{/if}
</td>
</tr>
{:else}
<tr><td colspan="2" class="py-8 text-[var(--site-muted)]">no Pokémon found</td></tr>
{/each}
</tbody>
</table>
<div class="mt-5 flex flex-wrap items-center justify-between gap-4">
<p class="text-[var(--site-muted)]">
{filteredPokemon.length ? (pageNumber - 1) * pageSize + 1 : 0}–{Math.min(
pageNumber * pageSize,
filteredPokemon.length
)} of {filteredPokemon.length}
</p>
<div class="flex items-center gap-4">
<button
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">
the catalog is owned by this site and built from a pinned PokéAPI snapshot. Checkboxes appear
for Pokémon you can catch (wild, gift, static or in-game trade), evolve from something you can
get in that game, or breed. “·” marks event-only mythicals, “?” means the Pokémon is in the
game but no route is confirmed, and “—” means there is no record. Neither proves it is
unobtainable. Newer games (Brilliant Diamond and later) have no encounter data yet.
</p>
</details>
</div>
<dialog
bind:this={detailsDialog}
onclose={() => (detailNumber = null)}
class="m-auto max-h-[90vh] w-[calc(100%-2rem)] max-w-2xl overflow-y-auto rounded-xl border border-[var(--site-border)] bg-[var(--site-surface)] p-5 text-[var(--site-text)] backdrop:bg-black/70 sm:p-6"
>
<div class="flex justify-end">
<button type="button" onclick={() => detailsDialog.close()} class="text-[var(--site-muted)]"
>close</button
>
</div>
{#if detailNumber !== null}
<PokemonDetails
number={detailNumber}
gameId={viewGameId}
isAdmin={data.isAdmin}
onsaved={() => invalidateAll()}
/>
{/if}
</dialog>
<dialog
bind:this={rulesDialog}
class="m-auto w-[calc(100%-2rem)] max-w-2xl rounded-xl border border-[var(--site-border)] bg-[var(--site-surface)] p-5 text-[var(--site-text)] backdrop:bg-black/70 sm:p-6"
>
<h2 class="text-2xl font-semibold">set rules</h2>
<p class="mt-2 text-[var(--site-muted)]">choose the games that count toward your progress.</p>
{#if form?.message}<p role="alert" class="mt-3 text-red-500">{form.message}</p>{/if}
<div class="mt-5 flex gap-5">
<button
type="button"
onclick={() => {
draft.clear();
for (const game of data.games) draft.add(game.id);
}}
class="underline underline-offset-4">all</button
>
<button type="button" onclick={() => draft.clear()} class="underline underline-offset-4"
>none</button
>
</div>
<form
method="POST"
action="?/setGames"
use:enhance={() => {
return async ({ result, update }) => {
await update();
if (result.type === 'success') rulesDialog.close();
};
}}
>
<div class="mt-5 grid max-h-[50vh] gap-5 overflow-y-auto pr-2 sm:grid-cols-2">
{#each gamesByGeneration as [generation, games] (generation)}
<fieldset>
<legend class="mb-2 font-semibold"
><label class="flex items-center gap-2"
><input
type="checkbox"
checked={games.every((game) => draft.has(game.id))}
onchange={(event) => {
for (const game of games) {
if (event.currentTarget.checked) draft.add(game.id);
else draft.delete(game.id);
}
}}
/>generation {generation}</label
></legend
>
{#each games as game (game.id)}<label class="flex items-center gap-2 py-1 pl-5"
><input
type="checkbox"
name="gameIds"
value={game.id}
checked={draft.has(game.id)}
onchange={(event) => {
if (event.currentTarget.checked) draft.add(game.id);
else draft.delete(game.id);
}}
/>{game.name}</label
>{/each}
</fieldset>
{/each}
</div>
<div class="mt-6 flex justify-end gap-5">
<button type="button" onclick={() => rulesDialog.close()}>cancel</button>
<button
disabled={!data.user}
title={data.user ? undefined : 'Sign in to save rules'}
class="rounded bg-[var(--site-text)] px-4 py-2 font-semibold text-[var(--site-bg)] disabled:opacity-40"
>save</button
>
</div>
</form>
</dialog>

View File

@@ -0,0 +1,11 @@
import { error, json } from '@sveltejs/kit';
import { pokemonDetail } from '$lib/server/pokemon-detail';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params, url }) => {
const game = Number(url.searchParams.get('game'));
const gameId = Number.isSafeInteger(game) && game > 0 && game <= 2147483647 ? game : null;
const number = Number(params.number);
if (number > 100000) error(404, 'Unknown Pokémon');
return json(await pokemonDetail(number, gameId));
};

View File

@@ -0,0 +1,19 @@
import { asc, countDistinct, eq, sql } from 'drizzle-orm';
import { db } from '$lib/server/db';
import { pokemonEncounters, pokemonLocations } from '$lib/server/db/schema';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => ({
locations: await db
.select({
id: pokemonLocations.id,
name: pokemonLocations.name,
region: pokemonLocations.region,
hasPicture: sql<boolean>`${pokemonLocations.imageKey} is not null`,
pokemon: countDistinct(pokemonEncounters.pokemonId)
})
.from(pokemonLocations)
.leftJoin(pokemonEncounters, eq(pokemonEncounters.locationId, pokemonLocations.id))
.groupBy(pokemonLocations.id)
.orderBy(asc(pokemonLocations.region), asc(pokemonLocations.name))
});

View File

@@ -0,0 +1,89 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
let search = $state('');
let region = $state('');
let withPokemonOnly = $state(true);
const regions = $derived([...new Set(data.locations.map((l) => l.region ?? 'other'))]);
const grouped = $derived(
Map.groupBy(
data.locations.filter(
(location) =>
(!withPokemonOnly || location.pokemon > 0) &&
(!region || (location.region ?? 'other') === region) &&
location.name.toLowerCase().includes(search.trim().toLowerCase())
),
(location) => location.region ?? 'other'
)
);
</script>
<div class="mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-14">
<a
href={resolve('/projects/game-tracker/pokemon')}
class="text-[var(--site-muted)] hover:text-[var(--site-text)]">← catchemall</a
>
<h1 class="mt-3 text-4xl font-semibold tracking-tight sm:text-5xl">locations</h1>
<p class="mt-2 text-[var(--site-muted)]">
{data.locations.length} places across every region. Open one to see which Pokémon live there.
</p>
<div class="mt-8 grid gap-4 sm:grid-cols-[minmax(0,1fr)_minmax(0,16rem)]">
<label class="block"
>search
<input
type="search"
bind:value={search}
placeholder="route, city, cave…"
class="mt-2 block w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-3"
/>
</label>
<label class="block"
>region
<select
bind:value={region}
class="mt-2 block w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-3"
>
<option value="">all regions</option>
{#each regions as name (name)}<option value={name}>{name}</option>{/each}
</select>
</label>
</div>
<label class="mt-5 flex cursor-pointer items-center gap-3">
<input
type="checkbox"
bind:checked={withPokemonOnly}
class="h-5 w-5 accent-[var(--site-text)]"
/>
only places with recorded Pokémon
</label>
{#each [...grouped] as [name, locations] (name)}
<section class="mt-10">
<h2 class="text-2xl font-semibold">{name}</h2>
<ul class="mt-3 grid gap-x-8 sm:grid-cols-2 lg:grid-cols-3">
{#each locations as location (location.id)}
<li class="border-t border-[var(--site-border)]">
<a
href={resolve('/projects/game-tracker/pokemon/locations/[id=integer]', {
id: String(location.id)
})}
class="flex items-baseline justify-between gap-3 py-3 hover:text-[var(--site-muted)]"
>
<span>{location.name}</span>
<span class="shrink-0 text-sm text-[var(--site-muted)]"
>{location.pokemon ? `${location.pokemon} Pokémon` : ''}{location.hasPicture
? ' · picture'
: ''}</span
>
</a>
</li>
{/each}
</ul>
</section>
{:else}
<p class="mt-10 text-[var(--site-muted)]">no locations match.</p>
{/each}
</div>

View File

@@ -0,0 +1,101 @@
import { error, fail } from '@sveltejs/kit';
import { asc, eq } from 'drizzle-orm';
import { db } from '$lib/server/db';
import { games, pokemon, pokemonEncounters, pokemonLocations } from '$lib/server/db/schema';
import { requireAdmin } from '$lib/server/authorization';
import { deleteObject, locationImageUrl, uploadImage } from '$lib/server/r2';
import type { Actions, PageServerLoad } from './$types';
const PICTURE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
const MAX_PICTURE_BYTES = 5 * 1024 * 1024;
async function findLocation(id: string) {
const [location] = await db
.select()
.from(pokemonLocations)
.where(eq(pokemonLocations.id, Number(id)));
if (!location) error(404, 'Location not found');
return location;
}
export const load: PageServerLoad = async ({ params }) => {
const location = await findLocation(params.id);
const encounters = await db
.select({
gameId: games.id,
game: games.name,
number: pokemon.number,
name: pokemon.name,
area: pokemonEncounters.area,
method: pokemonEncounters.method,
minLevel: pokemonEncounters.minLevel,
maxLevel: pokemonEncounters.maxLevel,
rarity: pokemonEncounters.rarity,
form: pokemonEncounters.form,
conditions: pokemonEncounters.conditions,
note: pokemonEncounters.note
})
.from(pokemonEncounters)
.innerJoin(games, eq(games.id, pokemonEncounters.gameId))
.innerJoin(pokemon, eq(pokemon.id, pokemonEncounters.pokemonId))
.where(eq(pokemonEncounters.locationId, location.id))
.orderBy(
asc(games.generation),
asc(games.id),
asc(pokemonEncounters.area),
asc(pokemonEncounters.method),
asc(pokemon.number)
);
return {
location: {
id: location.id,
name: location.name,
region: location.region,
description: location.description,
imageCredit: location.imageCredit,
imageUrl: locationImageUrl(location.imageKey)
},
encounters,
seo: {
title: `${location.name} · Pokémon locations`,
description: `Which Pokémon live in ${location.name}${location.region ? ` (${location.region})` : ''}, in which games, how to encounter them and at what levels.`
}
};
};
export const actions = {
save: async (event) => {
requireAdmin(event);
const location = await findLocation(event.params.id);
const data = await event.request.formData();
const description = String(data.get('description') ?? '').trim();
const imageCredit = String(data.get('imageCredit') ?? '').trim();
if (description.length > 5000 || imageCredit.length > 300)
return fail(400, { message: 'Description or credit is too long' });
let imageKey = location.imageKey;
const picture = data.get('picture');
if (picture instanceof File && picture.size > 0) {
if (!PICTURE_TYPES.includes(picture.type))
return fail(400, { message: 'Use a JPEG, PNG or WebP picture' });
if (picture.size > MAX_PICTURE_BYTES)
return fail(400, { message: 'Pictures can be at most 5 MB' });
try {
imageKey = (await uploadImage(picture, 'locations')).key;
} catch {
return fail(503, { message: 'Picture storage is not available right now' });
}
} else if (data.get('removePicture') === 'on') {
imageKey = null;
}
await db
.update(pokemonLocations)
.set({ description: description || null, imageCredit: imageCredit || null, imageKey })
.where(eq(pokemonLocations.id, location.id));
// Remove the replaced object only after the row no longer points at it.
if (location.imageKey && location.imageKey !== imageKey)
await deleteObject(location.imageKey).catch(() => undefined);
return { saved: true };
}
} satisfies Actions;

View File

@@ -0,0 +1,171 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { enhance } from '$app/forms';
import { page } from '$app/state';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
const games = $derived([
...new Map(data.encounters.map((encounter) => [encounter.gameId, encounter.game]))
]);
let gameId = $state<number | null>(null);
$effect(() => {
const requested = Number(page.url.searchParams.get('game'));
if (!games.some(([id]) => id === gameId))
gameId = games.find(([id]) => id === requested)?.[0] ?? games[0]?.[0] ?? null;
});
const rows = $derived(data.encounters.filter((encounter) => encounter.gameId === gameId));
const areas = $derived([...Map.groupBy(rows, (row) => row.area ?? '')]);
let saving = $state(false);
function levels(min: number | null, max: number | null) {
if (min === null) return '';
return min === max || max === null ? `Lv. ${min}` : `Lv. ${min}–${max}`;
}
</script>
<div class="mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-14">
<a
href={resolve('/projects/game-tracker/pokemon/locations')}
class="text-[var(--site-muted)] hover:text-[var(--site-text)]">← locations</a
>
<h1 class="mt-3 text-4xl font-semibold tracking-tight sm:text-5xl">{data.location.name}</h1>
{#if data.location.region}<p class="mt-2 text-[var(--site-muted)]">{data.location.region}</p>{/if}
<div class="mt-8 grid gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<figure>
{#if data.location.imageUrl}
<img
src={data.location.imageUrl}
alt={data.location.name}
class="w-full rounded-lg border border-[var(--site-border)]"
/>
{#if data.location.imageCredit}<figcaption class="mt-2 text-sm text-[var(--site-muted)]">
{data.location.imageCredit}
</figcaption>{/if}
{:else}
<div
class="flex aspect-video items-center justify-center rounded-lg border border-dashed border-[var(--site-border)] text-[var(--site-muted)]"
>
no picture yet
</div>
{/if}
</figure>
<div>
{#if data.location.description}
<p class="whitespace-pre-line">{data.location.description}</p>
{:else}
<p class="text-[var(--site-muted)]">no notes about this place yet.</p>
{/if}
</div>
</div>
{#if data.isAdmin}
<form
method="POST"
action="?/save"
enctype="multipart/form-data"
class="mt-8 border-t border-[var(--site-border)] pt-6"
use:enhance={() => {
saving = true;
return async ({ update }) => {
await update({ reset: false });
saving = false;
};
}}
>
<h2 class="text-xl font-semibold">edit location</h2>
{#if form?.message}<p role="alert" class="mt-3 text-red-500">{form.message}</p>{/if}
<label class="mt-4 block"
>notes
<textarea
name="description"
rows="4"
maxlength="5000"
class="mt-2 block w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2"
>{data.location.description ?? ''}</textarea
>
</label>
<div class="mt-4 grid gap-4 sm:grid-cols-2">
<label class="block"
>picture (JPEG, PNG or WebP, up to 5 MB)
<input
type="file"
name="picture"
accept="image/jpeg,image/png,image/webp"
class="mt-2 block w-full"
/>
</label>
<label class="block"
>picture credit
<input
name="imageCredit"
value={data.location.imageCredit ?? ''}
maxlength="300"
placeholder="e.g. my own screenshot"
class="mt-2 block w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2"
/>
</label>
</div>
{#if data.location.imageUrl}<label class="mt-3 flex items-center gap-2"
><input type="checkbox" name="removePicture" /> remove the current picture</label
>{/if}
<button
disabled={saving}
class="mt-5 rounded bg-[var(--site-text)] px-4 py-2 font-semibold text-[var(--site-bg)] disabled:opacity-40"
>{saving ? 'saving…' : 'save'}</button
>
</form>
{/if}
<section class="mt-12">
<div class="flex flex-wrap items-end justify-between gap-4">
<h2 class="text-2xl font-semibold">Pokémon here</h2>
{#if games.length}
<label class="block"
>game
<select
bind:value={gameId}
class="mt-2 block rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2"
>
{#each games as [id, name] (id)}<option value={id}>{name}</option>{/each}
</select>
</label>
{/if}
</div>
{#each areas as [area, list] (area)}
{#if area}<h3 class="mt-6 font-semibold">{area}</h3>{/if}
<ul class="mt-2">
{#each list as encounter, index (index)}
<li class="flex items-center gap-3 border-t border-[var(--site-border)] py-2">
<img
src="https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/{encounter.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)]">#{encounter.number}</span>{encounter.name}
<span class="block text-sm text-[var(--site-muted)]">
{[
encounter.method,
levels(encounter.minLevel, encounter.maxLevel),
encounter.rarity ? `${encounter.rarity}%` : '',
encounter.form ?? '',
...encounter.conditions,
encounter.note ?? ''
]
.filter(Boolean)
.join(' · ')}
</span>
</span>
</li>
{/each}
</ul>
{:else}
<p class="mt-4 text-[var(--site-muted)]">no Pokémon recorded here.</p>
{/each}
</section>
</div>