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>
285 lines
9.9 KiB
Svelte
285 lines
9.9 KiB
Svelte
<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>
|