Files
personal-website/src/routes/projects/game-tracker/call-of-duty/[slug]/+page.svelte
Stan Runge a10585a4d2
All checks were successful
Test and deploy staging / staging (push) Successful in 18s
add game tracker with owned pokemon data and call of duty mastery
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>
2026-09-24 17:36:18 +00:00

285 lines
9.9 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>