Files
personal-website/src/routes/projects/game-tracker/call-of-duty/[slug]/+page.svelte
Stan Runge c2ba8febb4
Some checks failed
Test and deploy staging / staging (push) Failing after 5m29s
Expand website and game trackers
2026-09-25 22:43:04 +00:00

641 lines
23 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 WeaponRankControls from '$lib/game-tracker/WeaponRankControls.svelte';
import WeaponArtwork from '$lib/game-tracker/WeaponArtwork.svelte';
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 { Checkbox } from '$lib/components/ui/checkbox/index.js';
import { NativeSelect } from '$lib/components/ui/native-select/index.js';
import Crosshair from 'lucide-svelte/icons/crosshair';
import Trophy from 'lucide-svelte/icons/trophy';
import Target from 'lucide-svelte/icons/target';
import ChevronDown from 'lucide-svelte/icons/chevron-down';
import * as Collapsible from '$lib/components/ui/collapsible/index.js';
import GameLogo from '$lib/game-tracker/GameLogo.svelte';
import ProgressMeter from '$lib/game-tracker/ProgressMeter.svelte';
import { combinedProgress, sumProgress, challengeProgress } from '$lib/game-tracker/progress';
import { playerRankLimits, weaponRankLimits } from '$lib/game-tracker/rank-limits';
let rankOpen = $state(false);
let rankWeapon = $state<number | null>(null);
let challengeSearch = $state('');
let challengeCategory = $state('all');
let rankSaving = $state(false);
let challengePage = $state(1);
let weaponSearch = $state('');
let weaponClass = $state('all');
let overviewOpen = $state(false);
import { onMount } from 'svelte';
import { resolve } from '$app/paths';
import { deserialize, enhance } from '$app/forms';
import { SvelteSet, SvelteMap } from 'svelte/reactivity';
import * as Tabs from '$lib/components/ui/tabs/index.js';
import { appliesTo, summarizeGame } from '$lib/game-tracker/mastery';
import type { PageProps } from './$types';
let { data, form }: 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 weaponRanks = $derived(new SvelteMap(data.weaponRanks.map((r) => [r.itemId, r])));
const isDone = (id: number, milestone: string) =>
done.has(`${id}:${milestone}`) ||
(milestone === 'maxLevel' && (weaponRanks.get(id)?.prestige ?? 0) > 0);
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
)
);
const masteryProgress = $derived(sumProgress(summary.milestones));
const unlockProgress = $derived(challengeProgress(data.challenges, data.challengeProgress));
const rankProgress = $derived({
value: weaponRanks.size + Number(!!data.rank),
total: weapons.length + 1
});
const overall = $derived(
combinedProgress([masteryProgress, rankProgress, summary.achievements, unlockProgress])
);
const classesProgress = $derived(
rules.classes.map((category) => {
const members = weapons.filter((w) => w.category === category);
const tasks = members.flatMap((w) =>
rules.milestones.filter((m) => appliesTo(m, w)).map((m) => isDone(w.id, m.key))
);
return { category, value: tasks.filter(Boolean).length, total: tasks.length };
})
);
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 weaponRows = $derived(
weapons.filter(
(w) =>
(weaponClass === 'all' || w.category === weaponClass) &&
w.name.toLowerCase().includes(weaponSearch.toLowerCase().trim()) &&
(!hideDone ||
rules.milestones.filter((m) => appliesTo(m, w)).some((m) => !isDone(w.id, m.key)))
)
);
const categories = $derived([...Map.groupBy(achievements, (item) => item.category)]);
const tabs = ['weapons', 'achievements', 'challenges'] as const;
let tab = $state<(typeof tabs)[number]>('weapons');
let hideDone = $state(false);
const rank = $derived(rankWeapon ? weaponRanks.get(rankWeapon) : data.rank);
const selectedWeapon = $derived(data.items.find((w) => w.id === rankWeapon));
const playerLimits = $derived(playerRankLimits(rules));
const rankLimits = $derived(
rankWeapon && selectedWeapon ? weaponRankLimits(rules, selectedWeapon) : playerLimits
);
const challengeCategories = $derived([...new Set(data.challenges.map((c) => c.category))]);
const challengeRows = $derived(
data.challenges.filter(
(c) =>
(challengeCategory === 'all' || c.category === challengeCategory) &&
`${c.name} ${c.requirement} ${c.reward ?? ''}`
.toLowerCase()
.includes(challengeSearch.toLowerCase()) &&
(!hideDone || !data.challengeProgress.find((p) => p.challengeId === c.id)?.done)
)
);
$effect(() => {
if (challengePage > Math.max(1, Math.ceil(challengeRows.length / 36))) challengePage = 1;
});
</script>
<div class="mx-auto max-w-[1600px] px-3 py-4 sm:px-5">
<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-center gap-3">
<GameLogo slug={`cod/${data.game.slug}`} name={data.game.shortName} class="h-12 w-24" />
<div class="flex-1">
<h1 class="text-2xl font-semibold tracking-tight">{data.game.shortName}</h1>
<p class="mt-2 text-muted-foreground">
Level {data.rank?.level ?? '—'}{playerLimits.maxLevel ? ` / ${playerLimits.maxLevel}` : ''} ·
Prestige {data.rank?.prestige ?? '—'}{data.game.rules.rankCaps
? ` / ${playerLimits.maxPrestige}`
: ''}{data.rank?.level === playerLimits.maxLevel &&
data.rank?.prestige === playerLimits.maxPrestige &&
data.game.rules.rankCaps
? ' · Prestige Master'
: ''}
</p>
</div>
<Button
variant="outline"
disabled={!hydrated || !data.user}
onclick={() => {
rankWeapon = null;
rankOpen = true;
}}>Update my rank</Button
>
</div>
<p class="mt-2 text-xs text-[var(--site-muted)]">
{weapons.length} weapons · {achievements.length} achievements · {data.challenges.length} challenges
& unlocks
</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 form?.message}<p role="alert" class="mt-4 text-destructive">{form.message}</p>{/if}
{#if saveError}<p role="alert" class="mt-5 text-red-500">{saveError}</p>{/if}
<div
class="mt-3 grid grid-cols-2 gap-3 rounded-xl border bg-card p-3 sm:grid-cols-5"
aria-label="Game completion"
>
<ProgressMeter
label="Game completion"
progress={overall}
color="violet"
compact
hint="Recorded ranks, weapon mastery, achievements and challenges contribute equally. Partial challenge targets count."
/>
<ProgressMeter label="Weapon mastery" progress={masteryProgress} color="amber" compact />
<ProgressMeter
label="Player & weapon ranks"
progress={rankProgress}
color="pink"
compact
hint="Percentage of this game’s player and weapon ranks that you have entered."
/>
<ProgressMeter label="Achievements" progress={summary.achievements} color="sky" compact />
<ProgressMeter label="Challenges & unlocks" progress={unlockProgress} compact />
</div>
<div
class="sticky top-0 z-20 mt-4 flex flex-wrap items-center justify-between gap-3 border-y bg-background/95 py-2"
>
<Tabs.Root bind:value={tab}
><Tabs.List
>{#each tabs as name (name)}<Tabs.Trigger disabled={!hydrated} value={name}>
{#if name === 'weapons'}<Crosshair class="mr-1.5 size-4" aria-hidden="true" />Weapons
{:else if name === 'achievements'}<Trophy
class="mr-1.5 size-4"
aria-hidden="true"
/>Achievements
{:else}<Target class="mr-1.5 size-4" aria-hidden="true" />Challenges{/if}</Tabs.Trigger
>{/each}</Tabs.List
></Tabs.Root
>
<Button variant="ghost" size="sm" onclick={() => (overviewOpen = true)}
>Progress overview</Button
>
<label class="flex cursor-pointer items-center gap-2 text-xs">
<Checkbox bind:checked={hideDone} />
Hide finished
</label>
</div>
{#if tab === 'weapons'}
<div class="my-3 flex flex-wrap items-center gap-2">
<Input
class="min-w-40 flex-1"
aria-label="Find a weapon"
type="search"
placeholder="Find a weapon…"
bind:value={weaponSearch}
/>
<NativeSelect aria-label="Weapon class" bind:value={weaponClass}
><option value="all">All weapon classes</option>{#each rules.classes as name (name)}<option
value={name}>{name}</option
>{/each}</NativeSelect
>
</div>
<div
class="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5"
aria-label="Weapon progress"
>
{#each weaponRows as weapon (weapon.id)}
{@const current = weaponRanks.get(weapon.id)}
{@const limits = weaponRankLimits(rules, weapon)}
{@const tiers = summary.mastery.weaponTiers(weapon)}
{@const allMilestones = rules.milestones.filter((m) => appliesTo(m, weapon))}
<article
class="weapon-card min-w-0 rounded-lg border bg-card p-2.5"
aria-label={weapon.name}
>
<div class="flex min-w-0 items-center gap-2">
<WeaponArtwork game={data.game.slug} name={weapon.name} icon={weapon.icon} />
<div class="min-w-0 flex-1">
<h2 class="truncate text-sm font-semibold" title={weapon.name}>{weapon.name}</h2>
<p class="truncate text-[10px] text-muted-foreground" title={weapon.category}>
{weapon.category}{weapon.postLaunch ? ' · DLC' : ''}
</p>
</div>
</div>
<div class="mt-2">
{#if limits.rankable}
<WeaponRankControls
itemId={weapon.id}
name={weapon.name}
level={current?.level ?? 1}
prestige={current?.prestige ?? 0}
levelCap={limits.maxLevel}
prestigeCap={limits.maxPrestige}
prestigeRequiresMaxLevel={limits.canPrestigeOnlyAtMaxLevel}
compact
disabled={!hydrated || !data.user}
onupdate={(r) => weaponRanks.set(r.itemId, r)}
onedit={() => {
rankWeapon = weapon.id;
rankOpen = true;
}}
/>
{:else}<p class="py-1 text-[10px] text-muted-foreground">No weapon level rank</p>{/if}
</div>
<div class="mt-2 flex items-center gap-2">
<ProgressMeter
label={`${weapon.name} mastery`}
progress={{
value: allMilestones.filter((m) => isDone(weapon.id, m.key)).length,
total: allMilestones.length
}}
color="amber"
compact
/>
{#if tiers.length}<span
class="shrink-0 text-[10px] text-amber-600"
title="Mastery tier"
>
{tiers.map((t) => t.label).join(' · ')}
</span>{/if}
</div>
{#if allMilestones.length}<Collapsible.Root class="mt-2 border-t pt-1.5">
<Collapsible.Trigger
class="group flex w-full items-center justify-between rounded py-1 text-left text-[11px] text-muted-foreground hover:text-foreground"
>
<span
>Camos & milestones <span class="ml-1 tabular-nums"
>{allMilestones.filter((m) => isDone(weapon.id, m.key))
.length}/{allMilestones.length}</span
></span
>
<ChevronDown
class="size-3.5 transition-transform group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</Collapsible.Trigger>
<Collapsible.Content class="mt-1 grid grid-cols-2 gap-x-2">
{#each allMilestones as milestone (milestone.key)}<label
class="flex min-h-7 items-center gap-1.5 text-[10px]"
><Checkbox
class="size-3.5"
checked={isDone(weapon.id, milestone.key)}
disabled={!hydrated ||
!data.user ||
pending.has(`${weapon.id}:${milestone.key}`)}
aria-label={`${weapon.name}: ${milestone.label}`}
title={milestone.description}
onCheckedChange={(checked) => toggle(weapon.id, milestone.key, checked)}
/>{milestone.label}</label
>{/each}
</Collapsible.Content>
</Collapsible.Root>{/if}
</article>
{:else}<p class="col-span-full py-8 text-center text-muted-foreground">
No weapons match these filters.
</p>{/each}
</div>
<p class="mt-3 text-xs text-muted-foreground">
Showing {weaponRows.length} of {weapons.length} weapons · Changes save immediately. Prestige + starts
the next prestige at level 1.
</p>
{:else if tab === 'achievements'}
{#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>
<div class="mt-3">
<ProgressMeter
label={`${category} achievements`}
progress={{
value: list.filter((item) => isDone(item.id, 'done')).length,
total: list.length
}}
color="sky"
compact
/>
</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>
<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}`}
onCheckedChange={(checked) => toggle(item.id, 'done', 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}
{:else}
<div class="mt-5 grid gap-3 sm:grid-cols-2">
<Input
type="search"
aria-label="Search challenges"
placeholder="Search challenges, emblems and calling cards…"
bind:value={challengeSearch}
oninput={() => (challengePage = 1)}
/><NativeSelect
class="w-full"
aria-label="Challenge category"
bind:value={challengeCategory}
onchange={() => (challengePage = 1)}
><option value="all">All categories</option
>{#each challengeCategories as category (category)}<option value={category}
>{category}</option
>{/each}</NativeSelect
>
</div>
<div class="mt-5 grid gap-4 md:grid-cols-2">
{#each challengeRows.slice((challengePage - 1) * 36, challengePage * 36) as challenge (challenge.id)}
{@const progress = data.challengeProgress.find((p) => p.challengeId === challenge.id)}
<article class="rounded-xl border bg-card p-5">
<div class="flex gap-3">
{#if challenge.icon}<img
src={challenge.icon}
alt=""
width="80"
height="48"
loading="lazy"
class="h-12 w-20 object-contain"
/>{/if}
<div>
<p class="text-xs text-muted-foreground">{challenge.category}</p>
<h2 class="font-semibold">{challenge.name}</h2>
</div>
</div>
<p class="mt-3 text-sm text-muted-foreground">{challenge.requirement}</p>
{#if challenge.reward}<p class="mt-2 text-sm">Unlock: {challenge.reward}</p>{/if}
<div class="mt-3">
<ProgressMeter
label={`${challenge.name} progress`}
progress={{
value: progress?.done ? (challenge.target ?? 1) : (progress?.current ?? 0),
total: challenge.target ?? 1
}}
detail={progress?.done
? 'Unlocked ✓'
: challenge.target
? `${progress?.current ?? 0} / ${challenge.target}`
: 'Not unlocked yet'}
/>
</div>
<form
method="POST"
action="?/saveChallenge"
use:enhance
class="mt-4 flex flex-wrap items-end gap-3"
>
<input
type="hidden"
name="challengeId"
value={challenge.id}
/>{#if challenge.target}<label class="flex-1 text-xs"
>Progress / {challenge.target}<Input
type="number"
name="current"
min={0}
max={challenge.target}
value={progress?.current ?? 0}
required
/></label
>{:else}<input type="hidden" name="current" value="0" />{/if}<label
class="mb-2 flex items-center gap-2 text-sm"
><Checkbox name="done" value="on" checked={progress?.done ?? false} />Unlocked</label
><Button type="submit" size="sm" variant="outline" disabled={!hydrated || !data.user}
>Save</Button
>
</form>
<!-- Catalog or validated external URL. -->
<!-- eslint-disable svelte/no-navigation-without-resolve -->
<a
href={challenge.sourceUrl}
target="_blank"
rel="noreferrer"
class="mt-3 inline-block text-xs text-muted-foreground underline"
>Source & requirements ↗</a
>
<!-- eslint-enable svelte/no-navigation-without-resolve -->
</article>
{:else}<p class="py-8 text-muted-foreground">
{data.challenges.length
? 'No matching challenges.'
: 'No multiplayer challenge catalog has been imported for this game yet.'}
</p>{/each}
</div>
<div class="mt-5 flex items-center justify-between">
<Button variant="outline" disabled={challengePage === 1} onclick={() => challengePage--}
>Previous</Button
><span class="text-sm text-muted-foreground"
>{challengeRows.length} challenges · {challengePage} / {Math.max(
1,
Math.ceil(challengeRows.length / 36)
)}</span
><Button
variant="outline"
disabled={challengePage * 36 >= challengeRows.length}
onclick={() => challengePage++}>Next</Button
>
</div>
<p class="mt-5 text-xs text-muted-foreground">
Requirements adapted from <a
href="https://callofduty.fandom.com/wiki/Challenges"
class="underline">Call of Duty Wiki contributors</a
>
under <a href="https://www.fandom.com/licensing" class="underline">CC BY-SA</a>. Each card
links to its source. This catalog includes confirmed source rows; coverage is not exhaustive.
</p>
{/if}
</div>
<Dialog.Root bind:open={rankOpen}
><Dialog.Content
><Dialog.Header
><Dialog.Title>{selectedWeapon?.name ?? 'Player'} level & prestige</Dialog.Title
><Dialog.Description
>Record your current rank in {data.game.shortName}. Weapon prestige also records that max
level has been reached.</Dialog.Description
></Dialog.Header
>{#if form?.message}<p role="alert" class="text-destructive">{form.message}</p>{/if}
<form
method="POST"
action="?/saveRank"
class="space-y-4"
use:enhance={() => {
rankSaving = true;
return async ({ result, update }) => {
await update();
rankSaving = false;
if (result.type === 'success') rankOpen = false;
};
}}
>
{#if rankWeapon}<input type="hidden" name="itemId" value={rankWeapon} />{/if}
<div class="grid grid-cols-2 gap-4">
<label
>Current level<Input
type="number"
name="level"
min={1}
max={rankLimits.maxLevel ?? 10000}
value={rank?.level ?? 1}
required
/></label
><label
>Prestige<Input
type="number"
name="prestige"
min={0}
max={rankLimits.maxPrestige}
value={rank?.prestige ?? 0}
required
/></label
>
</div>
<Dialog.Footer
><Button variant="outline" onclick={() => (rankOpen = false)}>Cancel</Button><Button
type="submit"
disabled={rankSaving || !data.user}>{rankSaving ? 'Saving…' : 'Save rank'}</Button
></Dialog.Footer
>
</form></Dialog.Content
></Dialog.Root
>
<Dialog.Root bind:open={overviewOpen}
><Dialog.Content class="max-h-[85dvh] overflow-y-auto sm:max-w-3xl"
><Dialog.Header
><Dialog.Title>Progress overview</Dialog.Title><Dialog.Description
>Mastery, camos and achievements in {data.game.shortName}.</Dialog.Description
></Dialog.Header
>
<div
class="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)}
<ProgressMeter label={bar.label} progress={bar} color="amber" hint={bar.description} />
{/each}
</div>
<h3 class="font-semibold">Weapon classes</h3>
<div class="grid gap-4 sm:grid-cols-2">
{#each classesProgress as group (group.category)}<ProgressMeter
label={group.category}
progress={group}
color="amber"
/>{/each}
</div>
<div class="grid gap-4 sm:grid-cols-2">
<ProgressMeter
label="Ranks entered"
progress={rankProgress}
color="pink"
detail="Your player rank plus a saved rank for each weapon."
/>
<ProgressMeter
label="Challenges & unlocks"
progress={unlockProgress}
detail={`${data.challengeProgress.filter((p) => p.done).length} unlocked · partial targets count too`}
/><ProgressMeter
label="Weapons prestiged"
progress={{
value: weapons.filter((w) => (weaponRanks.get(w.id)?.prestige ?? 0) > 0).length,
total: weapons.length
}}
color="violet"
/>
</div>
<p class="text-xs text-muted-foreground">
{[...rules.milestones, ...rules.tiers]
.filter((r) => r.description)
.map((r) => `${r.label}: ${r.description}`)
.join('. ')}. DLC weapons do not gate class or overall camos.
</p>
</Dialog.Content></Dialog.Root
>