games: active vs non-active games (games.library.active, owner toggles on the dashboard and each game ⋯ menu; nothing deleted). Header flyout and mobile menu list every game split into active / non-active; /trackers and home tiles show active chips and open both sections on hover; /start shows active with a non-active toggle. Games dashboard moved from /projects/game-tracker to /trackers/games (dashboard of active games only; old URL and /credits 301); deep trackers stay put
All checks were successful
Deploy production / deploy (push) Successful in 1m13s

This commit is contained in:
2026-10-25 20:23:15 +00:00
parent 60370d4572
commit 3ad13e5828
31 changed files with 31471 additions and 565 deletions

View File

@@ -0,0 +1 @@
ALTER TABLE "games"."library" ADD COLUMN "active" boolean DEFAULT true NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -18,6 +18,7 @@
children?: readonly Item[]; children?: readonly Item[];
icon?: string; icon?: string;
featured?: boolean; featured?: boolean;
inactive?: boolean;
locked?: boolean; locked?: boolean;
}; };
let { let {
@@ -33,18 +34,28 @@
} = $props(); } = $props();
const tracker = $derived(variant === 'tracker'); const tracker = $derived(variant === 'tracker');
// Big groups (games) show their featured chips first and the rest behind a "more" chip. // Big groups (games) show their featured chips (for games: the active ones, my pick) and the
// rest behind a "more" chip. Hovering the tile (or the chip) opens all of them; games then
// split into "active" and "non-active", like the header's Trackers dropdown.
let expanded = $state<Record<string, boolean>>({}); let expanded = $state<Record<string, boolean>>({});
let hovered = $state<string | null>(null);
const open = (group: Item) => !!expanded[group.name] || hovered === group.name;
const visibleChildren = (group: Item) => { const visibleChildren = (group: Item) => {
const all = group.children ?? []; const all = group.children ?? [];
return expanded[group.name] return open(group) ? all : all.some((c) => c.featured) ? all.filter((c) => c.featured) : all;
? all
: all.some((c) => c.featured)
? all.filter((c) => c.featured)
: all;
}; };
const hiddenCount = (group: Item) => const hiddenCount = (group: Item) =>
(group.children?.length ?? 0) - visibleChildren(group).length; (group.children?.length ?? 0) - visibleChildren(group).length;
const sections = (group: Item) => {
const shown = visibleChildren(group);
return open(group) && shown.some((c) => c.inactive)
? [
{ label: 'active', items: shown.filter((c) => !c.inactive) },
{ label: 'non-active', items: shown.filter((c) => c.inactive) }
]
: [{ label: '', items: shown }];
};
const hoverable = (group: Item) => (group.children ?? []).some((c) => c.featured);
</script> </script>
{#snippet body(project: Item)} {#snippet body(project: Item)}
@@ -81,7 +92,14 @@
<ul class="grid gap-3 sm:grid-cols-2" class:wide> <ul class="grid gap-3 sm:grid-cols-2" class:wide>
{#each items as project (project.name)} {#each items as project (project.name)}
<li> <li
onpointerenter={(event) => {
if (event.pointerType !== 'touch' && hoverable(project)) hovered = project.name;
}}
onpointerleave={() => {
if (hovered === project.name) hovered = null;
}}
>
<Card.Root class="catalog-card relative h-full py-0 transition-colors hover:bg-muted/50" <Card.Root class="catalog-card relative h-full py-0 transition-colors hover:bg-muted/50"
><Card.Content class="h-full p-0"> ><Card.Content class="h-full p-0">
{#if project.children} {#if project.children}
@@ -95,11 +113,20 @@
{@render body(project)} {@render body(project)}
</a> </a>
<ul class="relative z-10 mt-auto flex flex-wrap gap-1.5 pl-13"> <ul class="relative z-10 mt-auto flex flex-wrap gap-1.5 pl-13">
{#each visibleChildren(project) as child (child.name)} {#each sections(project) as section (section.label)}
{#if section.label}
<li class="chip-label">
{section.label}<span>{section.items.length}</span>
</li>
{/if}
{#each section.items as child (child.name)}
<li> <li>
<a <a
href={resolve(child.href as '/')} href={resolve(child.href as '/')}
class="chip flex items-center gap-1.5 py-1 pr-2.5 pl-1 text-xs font-medium" class={[
'chip flex items-center gap-1.5 py-1 pr-2.5 pl-1 text-xs font-medium',
child.inactive && 'inactive'
]}
title={child.subtitle} title={child.subtitle}
><TrackerIcon ><TrackerIcon
name={child.icon ?? child.name} name={child.icon ?? child.name}
@@ -108,7 +135,8 @@
> >
</li> </li>
{/each} {/each}
{#if hiddenCount(project) > 0 || expanded[project.name]} {/each}
{#if hiddenCount(project) > 0 || (expanded[project.name] && hovered !== project.name)}
<li> <li>
<button <button
type="button" type="button"
@@ -206,6 +234,33 @@
border-color: color-mix(in oklab, var(--foreground) 35%, transparent); border-color: color-mix(in oklab, var(--foreground) 35%, transparent);
transform: translateY(-1px); transform: translateY(-1px);
} }
.chip-label {
flex-basis: 100%;
display: flex;
align-items: baseline;
gap: 0.4rem;
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--site-muted);
}
.chip-label span {
letter-spacing: 0;
opacity: 0.7;
font-variant-numeric: tabular-nums;
}
.chip-label:not(:first-child) {
margin-top: 0.35rem;
}
.chip.inactive :global(.chip-icon) {
filter: grayscale(0.6);
opacity: 0.75;
}
.chip.inactive:hover :global(.chip-icon) {
filter: none;
opacity: 1;
}
.chip:focus-visible { .chip:focus-visible {
outline: 2px solid var(--foreground); outline: 2px solid var(--foreground);
outline-offset: 2px; outline-offset: 2px;

View File

@@ -38,6 +38,7 @@
user: { name: string } | null; user: { name: string } | null;
isOwner: boolean; isOwner: boolean;
version: string; version: string;
inactiveGames?: string[];
}; };
let { data, children }: { data: ChromeData; children: Snippet } = $props(); let { data, children }: { data: ChromeData; children: Snippet } = $props();
const showFooter = $derived(!hidesFooter(page.url.pathname)); const showFooter = $derived(!hidesFooter(page.url.pathname));
@@ -59,10 +60,12 @@
href: string; href: string;
external: boolean; external: boolean;
children?: readonly MenuItem[]; children?: readonly MenuItem[];
icon?: string;
inactive?: boolean;
}; };
const menus: Record<string, { label: string; items: readonly MenuItem[] }> = $derived({ const menus: Record<string, { label: string; items: readonly MenuItem[] }> = $derived({
'/projects': { label: 'projects', items: projects }, '/projects': { label: 'projects', items: projects },
'/trackers': { label: 'trackers', items: visibleTrackers(data.isOwner) } '/trackers': { label: 'trackers', items: visibleTrackers(data.isOwner, data.inactiveGames) }
}); });
const socialLinks = [ const socialLinks = [
{ icon: SiMail, label: 'email', href: 'mailto:stan@stanrunge.dev' }, { icon: SiMail, label: 'email', href: 'mailto:stan@stanrunge.dev' },
@@ -197,7 +200,7 @@
if (flyout === name) return; if (flyout === name) return;
const columns = flyoutColumns(count); const columns = flyoutColumns(count);
const width = columns === 3 ? 640 : columns === 2 ? 432 : 240; const width = columns === 3 ? 640 : columns === 2 ? 432 : 240;
const height = Math.min(Math.ceil(count / columns) * 46 + 20, window.innerHeight - 80); const height = Math.min(Math.ceil(count / columns) * 46 + 80, window.innerHeight - 80);
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
const panel = el.closest('[data-menu-panel]')?.getBoundingClientRect() ?? rect; const panel = el.closest('[data-menu-panel]')?.getBoundingClientRect() ?? rect;
const leftColumn = rect.left + rect.width / 2 < panel.left + panel.width / 2; const leftColumn = rect.left + rect.width / 2 < panel.left + panel.width / 2;
@@ -207,6 +210,15 @@
flyoutSide = leftColumn ? (roomLeft ? 'left' : 'right') : roomRight ? 'right' : 'left'; flyoutSide = leftColumn ? (roomLeft ? 'left' : 'right') : roomRight ? 'right' : 'left';
flyout = name; flyout = name;
} }
// Games split into what I play now and the rest (games.library.active); other groups have
// one unlabelled section.
const childSections = (items: readonly MenuItem[]) =>
items.some((c) => c.inactive)
? [
{ label: 'active', items: items.filter((c) => !c.inactive) },
{ label: 'non-active', items: items.filter((c) => c.inactive) }
]
: [{ label: '', items }];
function closeFlyoutOnEscape(event: KeyboardEvent) { function closeFlyoutOnEscape(event: KeyboardEvent) {
if (event.key !== 'Escape' || !flyout) return; if (event.key !== 'Escape' || !flyout) return;
event.stopPropagation(); event.stopPropagation();
@@ -385,10 +397,19 @@
]} ]}
class:from-left={flyoutSide === 'left'} class:from-left={flyoutSide === 'left'}
> >
{#each project.children as child (child.name)} {#each childSections(project.children) as section (section.label)}
{#if section.label}
<p class="flyout-label col-span-full">
{section.label}<span>{section.items.length}</span>
</p>
{/if}
{#each section.items as child (child.name)}
<a <a
href={resolve(child.href as '/')} href={resolve(child.href as '/')}
class="tile flex min-w-0 items-center gap-2.5 p-1.5 text-sm" class={[
'tile flex min-w-0 items-center gap-2.5 p-1.5 text-sm',
child.inactive && 'inactive'
]}
title={`${child.name}: ${child.subtitle}`} title={`${child.name}: ${child.subtitle}`}
aria-current={current(child.href) ? 'page' : undefined} aria-current={current(child.href) ? 'page' : undefined}
onkeydown={closeFlyoutOnEscape} onkeydown={closeFlyoutOnEscape}
@@ -398,6 +419,7 @@
/><span class="truncate font-medium">{child.name}</span></a /><span class="truncate font-medium">{child.name}</span></a
> >
{/each} {/each}
{/each}
</div> </div>
</div> </div>
{/if} {/if}
@@ -551,10 +573,19 @@
<div class="col-span-2 mt-1"> <div class="col-span-2 mt-1">
<span class="mb-1 block text-xs text-muted-foreground">{group.name}</span> <span class="mb-1 block text-xs text-muted-foreground">{group.name}</span>
<div class="ml-1 grid grid-cols-2 gap-1.5 border-l border-border pl-2"> <div class="ml-1 grid grid-cols-2 gap-1.5 border-l border-border pl-2">
{#each group.children ?? [] as child (child.name)} {#each childSections(group.children ?? []) as section (section.label)}
{#if section.label}
<p class="flyout-label col-span-full">
{section.label}<span>{section.items.length}</span>
</p>
{/if}
{#each section.items as child (child.name)}
<a <a
href={resolve(child.href as '/')} href={resolve(child.href as '/')}
class="tile flex min-w-0 items-center gap-2 p-1.5 text-sm" class={[
'tile flex min-w-0 items-center gap-2 p-1.5 text-sm',
child.inactive && 'inactive'
]}
onclick={() => (menuOpen = false)} onclick={() => (menuOpen = false)}
><TrackerIcon ><TrackerIcon
name={child.icon ?? child.name} name={child.icon ?? child.name}
@@ -562,6 +593,7 @@
/><span class="truncate">{child.name}</span></a /><span class="truncate">{child.name}</span></a
> >
{/each} {/each}
{/each}
</div> </div>
</div> </div>
{/each} {/each}
@@ -617,7 +649,7 @@
{#if showFooter} {#if showFooter}
<footer <footer
class="mt-16 border-t border-[var(--site-border)]" class="mt-16 border-t border-[var(--site-border)]"
class:tracker-footer={page.route.id === '/projects/game-tracker'} class:tracker-footer={page.route.id === '/trackers/games'}
class:cod-footer={page.route.id === '/projects/game-tracker/call-of-duty/[slug]'} class:cod-footer={page.route.id === '/projects/game-tracker/call-of-duty/[slug]'}
> >
<div class="mx-auto max-w-6xl space-y-5 px-4 py-8 text-[var(--site-muted)] sm:px-6"> <div class="mx-auto max-w-6xl space-y-5 px-4 py-8 text-[var(--site-muted)] sm:px-6">
@@ -699,6 +731,36 @@
background: color-mix(in oklab, var(--foreground) 9%, transparent); background: color-mix(in oklab, var(--foreground) 9%, transparent);
border-color: color-mix(in oklab, var(--foreground) 35%, transparent); border-color: color-mix(in oklab, var(--foreground) 35%, transparent);
} }
.flyout-label {
display: flex;
align-items: baseline;
gap: 0.4rem;
padding: 0.35rem 0.4rem 0.1rem;
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--muted-foreground);
}
.flyout-label span {
font-variant-numeric: tabular-nums;
letter-spacing: 0;
opacity: 0.7;
}
.flyout-label:not(:first-child) {
margin-top: 0.35rem;
border-top: 1px solid var(--border);
padding-top: 0.55rem;
}
.tile.inactive :global(.tile-icon) {
filter: grayscale(0.6);
opacity: 0.75;
}
.tile.inactive:hover :global(.tile-icon),
.tile.inactive:focus-visible :global(.tile-icon) {
filter: none;
opacity: 1;
}
.flyout { .flyout {
animation: flyout-in 140ms cubic-bezier(0.2, 0.8, 0.2, 1); animation: flyout-in 140ms cubic-bezier(0.2, 0.8, 0.2, 1);
--flyout-from: -4px; --flyout-from: -4px;

View File

@@ -12,7 +12,7 @@ export type GameLogo = {
bleed?: boolean; bleed?: boolean;
/** Wide wordmark (Pokémon logos): use more of the tile so it stays readable. */ /** Wide wordmark (Pokémon logos): use more of the tile so it stays readable. */
wide?: boolean; wide?: boolean;
/** Where the asset came from (shown on /projects/game-tracker/credits). */ /** Where the asset came from (shown on /trackers/games/credits). */
credit: string; credit: string;
source: string; source: string;
}; };

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { visibleTrackers } from './projects'; import { activeGames, pokemonEntryFor, sortedGames, visibleTrackers } from './projects';
describe('trackers', () => { describe('trackers', () => {
it('lists tasks, money, reading first for the owner', () => { it('lists tasks, money, reading first for the owner', () => {
@@ -28,4 +28,28 @@ describe('trackers', () => {
expect(owner.find((t) => t.name === 'money')?.locked).toBe(true); expect(owner.find((t) => t.name === 'money')?.locked).toBe(true);
expect(owner.find((t) => t.name === 'reading')?.locked).toBeUndefined(); expect(owner.find((t) => t.name === 'reading')?.locked).toBeUndefined();
}); });
it('splits games into active first and non-active (flagged) after', () => {
const off = ['lol', 'black-ops', 'pokemon-x-y'];
const games = visibleTrackers(true, off).find((t) => t.name === 'games')!.children!;
const inactive = games.filter((g) => g.inactive).map((g) => g.href);
expect(inactive).toEqual([
'/trackers/games/lol',
'/trackers/games/black-ops',
'/trackers/games/pokemon-x-y'
]);
expect(games.slice(-3).every((g) => g.inactive)).toBe(true);
expect(games.filter((g) => !g.inactive).every((g) => g.featured)).toBe(true);
expect(activeGames(off)).toHaveLength(sortedGames(off).length - 3);
expect(
visibleTrackers(true)
.find((t) => t.name === 'games')!
.children!.some((g) => g.inactive)
).toBe(false);
});
it('maps Pokémon journal games to their generation entry', () => {
expect(pokemonEntryFor('platinum')).toBe('pokemon-diamond-pearl-platinum');
expect(pokemonEntryFor('soulsilver')).toBe('pokemon-heartgold-soulsilver');
expect(pokemonEntryFor('the-teal-mask-violet')).toBe('pokemon-scarlet-violet');
expect(pokemonEntryFor('champions')).toBeUndefined();
});
}); });

View File

@@ -43,6 +43,8 @@ type Tracker = {
icon?: string; icon?: string;
/** Shown on group tiles before the "more" chip (main and actively played games). */ /** Shown on group tiles before the "more" chip (main and actively played games). */
featured?: boolean; featured?: boolean;
/** A game I don't play now (games.library.active = false): listed under "non-active". */
inactive?: boolean;
/** Owner-only (needs login): every listing shows a small lock after the name. */ /** Owner-only (needs login): every listing shows a small lock after the name. */
locked?: boolean; locked?: boolean;
}; };
@@ -82,6 +84,9 @@ export const spottingTrackers: readonly Tracker[] = [
// own page at /trackers/games/<slug>, one click away from the header flyout and the chips. // own page at /trackers/games/<slug>, one click away from the header flyout and the chips.
// `featured` games (my main game and the ones I actively play) show first on tiles; the // `featured` games (my main game and the ones I actively play) show first on tiles; the
// rest sit behind a "more" chip. Data per game lives in games.library (same slugs). // rest sit behind a "more" chip. Data per game lives in games.library (same slugs).
// Which games are active (the ones I play now) is my pick in games.library.active, toggled on
// /trackers/games; sortedGames() puts them first, makes them the tile chips and flags the
// rest `inactive`, so the header, /start and the tiles show "active" and "non-active" parts.
// Call of Duty is one entry per title and Pokémon one per generation (remakes such as // Call of Duty is one entry per title and Pokémon one per generation (remakes such as
// HeartGold & SoulSilver and spin-offs separate); full names, no abbreviations. The old // HeartGold & SoulSilver and spin-offs separate); full names, no abbreviations. The old
// /trackers/games/bo2 and /trackers/games/pokemon URLs redirect (see [slug]/+page.server.ts). // /trackers/games/bo2 and /trackers/games/pokemon URLs redirect (see [slug]/+page.server.ts).
@@ -185,6 +190,66 @@ export const gameTrackers: readonly Tracker[] = [
game('pokemon-legends-z-a', 'pokémon legends: z-a', 'generation 9, Nintendo Switch') game('pokemon-legends-z-a', 'pokémon legends: z-a', 'generation 9, Nintendo Switch')
]; ];
/** games.library slug of a game tracker entry (`/trackers/games/<slug>`). */
export const gameSlug = (t: { href: string }) => t.href.replace('/trackers/games/', '');
/** Every game, active ones first (games.library.active, my pick), non-active ones flagged
* `inactive`; `inactiveGames` comes from the root layout data. */
export function sortedGames(inactiveGames: readonly string[] = []): readonly Tracker[] {
if (!inactiveGames.length) return gameTrackers;
const off = new Set(inactiveGames);
const flagged = gameTrackers.map((g) =>
off.has(gameSlug(g)) ? { ...g, inactive: true, featured: false } : { ...g, featured: true }
);
return [...flagged.filter((g) => !g.inactive), ...flagged.filter((g) => g.inactive)];
}
/** Only the games I play now. */
export const activeGames = (inactiveGames: readonly string[] = []) =>
sortedGames(inactiveGames).filter((g) => !g.inactive);
// Which games.library entry each Pokémon journal game (ref.pokemon_games.slug) belongs to,
// so hiding a generation also hides its games on the hub. Unlisted games always show.
const POKEMON_ENTRY_GAMES: Record<string, readonly string[]> = {
'pokemon-red-blue-yellow': ['red', 'blue', 'yellow', 'red-japan', 'blue-japan', 'green-japan'],
'pokemon-gold-silver-crystal': ['gold', 'silver', 'crystal'],
'pokemon-ruby-sapphire-emerald': ['ruby', 'sapphire', 'emerald'],
'pokemon-colosseum': ['colosseum'],
'pokemon-xd': ['xd'],
'pokemon-firered-leafgreen': ['firered', 'leafgreen'],
'pokemon-diamond-pearl-platinum': ['diamond', 'pearl', 'platinum'],
'pokemon-heartgold-soulsilver': ['heartgold', 'soulsilver'],
'pokemon-black-white': ['black', 'white', 'black-2', 'white-2'],
'pokemon-x-y': ['x', 'y'],
'pokemon-omega-ruby-alpha-sapphire': ['omega-ruby', 'alpha-sapphire'],
'pokemon-sun-moon': ['sun', 'moon', 'ultra-sun', 'ultra-moon'],
'pokemon-lets-go': ['lets-go-pikachu', 'lets-go-eevee'],
'pokemon-sword-shield': [
'sword',
'shield',
'the-isle-of-armor-sword',
'the-isle-of-armor-shield',
'the-crown-tundra-sword',
'the-crown-tundra-shield'
],
'pokemon-brilliant-diamond-shining-pearl': ['brilliant-diamond', 'shining-pearl'],
'pokemon-legends-arceus': ['legends-arceus'],
'pokemon-scarlet-violet': [
'scarlet',
'violet',
'the-teal-mask-scarlet',
'the-teal-mask-violet',
'the-indigo-disk-scarlet',
'the-indigo-disk-violet'
],
'pokemon-legends-z-a': ['legends-za', 'mega-dimension']
};
const POKEMON_GAME_ENTRY = new Map(
Object.entries(POKEMON_ENTRY_GAMES).flatMap(([entry, games]) => games.map((g) => [g, entry]))
);
/** games.library entry for a Pokémon journal game slug, if it has one. */
export const pokemonEntryFor = (slug: string | null | undefined) =>
slug ? POKEMON_GAME_ENTRY.get(slug) : undefined;
// Trackers, in display order. Names are short single nouns. Most keep their existing // Trackers, in display order. Names are short single nouns. Most keep their existing
// /projects/... URLs (no moves, so no redirects needed). `private` trackers are shown in the // /projects/... URLs (no moves, so no redirects needed). `private` trackers are shown in the
// header, home page and /trackers only to the logged-in owner (layout data `isOwner`), never // header, home page and /trackers only to the logged-in owner (layout data `isOwner`), never
@@ -273,7 +338,7 @@ const allTrackers: readonly (Tracker & { private: boolean })[] = [
{ {
name: 'games', name: 'games',
subtitle: 'every pokemon in every game, and call of duty camo mastery', subtitle: 'every pokemon in every game, and call of duty camo mastery',
href: '/projects/game-tracker', href: '/trackers/games',
external: false, external: false,
children: gameTrackers, children: gameTrackers,
private: false private: false
@@ -323,6 +388,14 @@ export const trackers: readonly Tracker[] = allTrackers.filter((t) => !t.private
export const privateTrackers: readonly Tracker[] = allTrackers.filter((t) => t.private).map(strip); export const privateTrackers: readonly Tracker[] = allTrackers.filter((t) => t.private).map(strip);
const ownerTrackers: readonly Tracker[] = allTrackers.map(strip); const ownerTrackers: readonly Tracker[] = allTrackers.map(strip);
export function visibleTrackers(isOwner: boolean): readonly Tracker[] { /** Trackers for the header, home, /trackers, /start and error pages; with `inactiveGames` (root
return isOwner ? ownerTrackers : trackers; * layout data) the games group lists active games first and flags the rest `inactive`. */
export function visibleTrackers(
isOwner: boolean,
inactiveGames: readonly string[] = []
): readonly Tracker[] {
const list = isOwner ? ownerTrackers : trackers;
if (!inactiveGames.length) return list;
const games = sortedGames(inactiveGames);
return list.map((t) => (t.children === gameTrackers ? { ...t, children: games } : t));
} }

View File

@@ -76,7 +76,7 @@ const publicPages: Record<string, { title: string; description: string }> = {
'Stan Runge’s PC setup: his PC, desk and devices on one screen with a time slider back to 2021, the planned next build and mouse sensitivities.' 'Stan Runge’s PC setup: his PC, desk and devices on one screen with a time slider back to 2021, the planned next build and mouse sensitivities.'
}, },
'/lists': { title: 'Lists', description: 'Lists collected and maintained by Stan Runge.' }, '/lists': { title: 'Lists', description: 'Lists collected and maintained by Stan Runge.' },
'/projects/game-tracker': { '/trackers/games': {
title: 'Game Tracker', title: 'Game Tracker',
description: description:
'Pokémon adventures and collections, Call of Duty ranks and unlocks, and osu! scores and training.' 'Pokémon adventures and collections, Call of Duty ranks and unlocks, and osu! scores and training.'

View File

@@ -1,11 +1,12 @@
import { sql } from 'drizzle-orm'; import { sql } from 'drizzle-orm';
import { check, integer, text } from 'drizzle-orm/pg-core'; import { boolean, check, integer, text } from 'drizzle-orm/pg-core';
import { gamesSchema } from './schemas'; import { gamesSchema } from './schemas';
import { id, timestamps } from './helpers'; import { id, timestamps } from './helpers';
// Every game I play (plus speedcubing): one row per game, shown at /trackers/games/<slug>. // Every game I play (plus speedcubing): one row per game, shown at /trackers/games/<slug>.
// The header, home, /trackers and /start chips come from `gameTrackers` in #lib/projects // The header, home, /trackers and /start chips come from `gameTrackers` in #lib/projects
// (static, so navigation needs no query); keep the slugs in sync with these rows. // (static, so navigation needs no query); keep the slugs in sync with these rows. Only the
// `active` flag is dynamic: the root layout loads the non-active slugs (#lib/server/games/active).
// Deep trackers (osu! journal, Call of Duty mastery, Pokémon catches) stay where they are // Deep trackers (osu! journal, Call of Duty mastery, Pokémon catches) stay where they are
// and are linked through `tracker_href`. // and are linked through `tracker_href`.
export const GAME_STATUSES = ['main', 'active', 'occasional', 'unknown'] as const; export const GAME_STATUSES = ['main', 'active', 'occasional', 'unknown'] as const;
@@ -36,6 +37,9 @@ export const gameLibrary = gamesSchema.table(
.default(sql`'{}'::text[]`), .default(sql`'{}'::text[]`),
notes: text('notes'), notes: text('notes'),
sortOrder: integer('sort_order').notNull().default(100), sortOrder: integer('sort_order').notNull().default(100),
/** Games I play now (my pick, toggled on /trackers/games). Non-active games keep their
* data and page; navigation lists them in a separate "non-active" section. */
active: boolean('active').notNull().default(true),
...timestamps ...timestamps
}, },
(t) => [ (t) => [

View File

@@ -0,0 +1,37 @@
import { asc, eq } from 'drizzle-orm';
import { db } from '#lib/server/db/index.js';
import { gameLibrary } from '#lib/server/db/schema/index.js';
// Games I don't play now (games.library.active = false). The root layout loads these slugs on
// every request so the header, home, /trackers, /start and the /trackers/games dashboard can
// split active from non-active, so keep a short in-memory cache; setGameActive clears it.
const TTL_MS = 60_000;
let cache: { at: number; slugs: string[] } | undefined;
export async function loadInactiveGames(): Promise<string[]> {
if (cache && Date.now() - cache.at < TTL_MS) return cache.slugs;
try {
const rows = await db
.select({ slug: gameLibrary.slug })
.from(gameLibrary)
.where(eq(gameLibrary.active, false))
.orderBy(asc(gameLibrary.sortOrder));
cache = { at: Date.now(), slugs: rows.map((r) => r.slug) };
return cache.slugs;
} catch (err) {
// Navigation must never break on this: treat every game as active if the query fails.
console.error('games: could not load the non-active games', err);
return cache?.slugs ?? [];
}
}
/** Mark one game active or non-active; false when the slug is not in games.library. */
export async function setGameActive(slug: string, active: boolean): Promise<boolean> {
const rows = await db
.update(gameLibrary)
.set({ active, updatedAt: new Date() })
.where(eq(gameLibrary.slug, slug))
.returning({ slug: gameLibrary.slug });
cache = undefined;
return rows.length > 0;
}

View File

@@ -38,7 +38,7 @@ export function getTitle(path: string, environment: SiteEnvironment = null): str
'/projects/derby': 'Derby Lens', '/projects/derby': 'Derby Lens',
'/projects/cs-prices': 'CS Prices', '/projects/cs-prices': 'CS Prices',
'/projects/energy': 'Energy', '/projects/energy': 'Energy',
'/projects/game-tracker': 'Game Tracker', '/trackers/games': 'Game Tracker',
'/projects/language-learner': 'Language Learner', '/projects/language-learner': 'Language Learner',
'/projects/spotify-beatmap-finder': 'osu! map finder', '/projects/spotify-beatmap-finder': 'osu! map finder',
'/projects/progress': 'Progress', '/projects/progress': 'Progress',

View File

@@ -1,10 +1,11 @@
import { isAdmin } from '#lib/server/authorization.js'; import { isAdmin } from '#lib/server/authorization.js';
import { isOwner } from '#lib/server/owner.js'; import { isOwner } from '#lib/server/owner.js';
import { loadInactiveGames } from '#lib/server/games/active.js';
import { dev } from '$app/env'; import { dev } from '$app/env';
import { siteEnvironment } from '#lib/title.js'; import { siteEnvironment } from '#lib/title.js';
import type { LayoutServerLoad } from './$types'; import type { LayoutServerLoad } from './$types';
const environment = siteEnvironment(dev, process.env.ORIGIN); const environment = siteEnvironment(dev, process.env.ORIGIN);
export const load: LayoutServerLoad = (event) => ({ export const load: LayoutServerLoad = async (event) => ({
user: event.locals.user user: event.locals.user
? { id: event.locals.user.id, name: event.locals.user.name, image: event.locals.user.image } ? { id: event.locals.user.id, name: event.locals.user.name, image: event.locals.user.image }
: null, : null,
@@ -12,5 +13,7 @@ export const load: LayoutServerLoad = (event) => ({
isOwner: isOwner(event), isOwner: isOwner(event),
editable: isAdmin(event), editable: isAdmin(event),
version: process.env.APP_VERSION || 'dev', version: process.env.APP_VERSION || 'dev',
environment environment,
/** Games I don't play now: navigation lists them under "non-active" (#lib/projects). */
inactiveGames: await loadInactiveGames()
}); });

View File

@@ -138,7 +138,7 @@
> >
</h2> </h2>
<CatalogGrid <CatalogGrid
items={visibleTrackers(data.isOwner)} items={visibleTrackers(data.isOwner, data.inactiveGames)}
variant="tracker" variant="tracker"
subtitleClass="mt-1 block text-base text-[var(--site-muted)]" subtitleClass="mt-1 block text-base text-[var(--site-muted)]"
/> />

View File

@@ -1,172 +1,6 @@
import { asc, eq } from 'drizzle-orm'; import { redirect } from '@sveltejs/kit';
import { db } from '#lib/server/db/index.js';
import {
trackerGames,
trackerItems,
trackerProgress,
trackerWeaponRanks,
trackerRanks,
trackerChallenges,
trackerChallengeProgress,
osuEntries
} from '#lib/server/db/schema/index.js';
import { loadPokemonProgress } from '#lib/server/tracker-progress.js';
import { challengeProgress, combinedProgress, sumProgress } from '#lib/game-tracker/progress.js';
import { gameRanksProgress, hasReachedMaxLevel } from '#lib/game-tracker/rank-progress.js';
import { summarizeGame } from '#lib/game-tracker/mastery.js';
import { chronologicalPokemon, pokemonRelease } from '#lib/game-tracker/game-art.js';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
async function pokemonSummary(userId: string | undefined) { // The games dashboard moved to /trackers/games (the deep trackers, such as /pokemon, /osu and
const progress = await loadPokemonProgress(userId); // /call-of-duty/<title>, stay under /projects/game-tracker).
return { export const load: PageServerLoad = ({ url }) => redirect(301, `/trackers/games${url.search}`);
...progress,
games: chronologicalPokemon(progress.configured ? progress.games : []).map((g) => ({
...g,
releaseYear: pokemonRelease(g.slug).slice(0, 4)
}))
};
}
async function callOfDutySummary(userId: string | undefined) {
const [gamesList, items, progress, ranks, playerRanks, challenges, challengeRows] =
await Promise.all([
db
.select()
.from(trackerGames)
.where(eq(trackerGames.franchise, 'call-of-duty'))
.orderBy(asc(trackerGames.releaseYear), 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([]),
userId
? db
.select({
itemId: trackerWeaponRanks.itemId,
prestige: trackerWeaponRanks.prestige,
level: trackerWeaponRanks.level
})
.from(trackerWeaponRanks)
.where(eq(trackerWeaponRanks.userId, userId))
: Promise.resolve([]),
userId
? db
.select({
gameId: trackerRanks.gameId,
level: trackerRanks.level,
prestige: trackerRanks.prestige
})
.from(trackerRanks)
.where(eq(trackerRanks.userId, userId))
: Promise.resolve([]),
db
.select({
id: trackerChallenges.id,
gameId: trackerChallenges.gameId,
target: trackerChallenges.target
})
.from(trackerChallenges),
userId
? db
.select({
challengeId: trackerChallengeProgress.challengeId,
current: trackerChallengeProgress.current,
done: trackerChallengeProgress.done
})
.from(trackerChallengeProgress)
.where(eq(trackerChallengeProgress.userId, userId))
: []
]);
const done = new Set(progress.map((row) => `${row.itemId}:${row.milestone}`));
for (const rank of ranks) if (rank.prestige > 0) done.add(`${rank.itemId}:maxLevel`);
const byGame = Map.groupBy(items, (item) => item.gameId);
return gamesList.map((game) => {
const list = byGame.get(game.id) ?? [];
const weapons = list.filter((i) => i.kind === 'weapon');
const rankMap = new Map(ranks.map((r) => [r.itemId, r]));
for (const weapon of weapons)
if (hasReachedMaxLevel(game.rules, weapon, rankMap.get(weapon.id)))
done.add(`${weapon.id}:maxLevel`);
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}`)
);
const weaponProgress = sumProgress(summary.milestones);
const challengesProgress = challengeProgress(
challenges.filter((c) => c.gameId === game.id),
challengeRows
);
const ranksProgress = gameRanksProgress(
game.rules,
weapons,
rankMap,
playerRanks.find((r) => r.gameId === game.id)
);
return {
overall: combinedProgress([
weaponProgress,
ranksProgress,
summary.achievements,
challengesProgress
]),
weaponProgress,
challengesProgress,
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, osu] = await Promise.all([
pokemonSummary(locals.user?.id),
callOfDutySummary(locals.user?.id),
locals.user
? db
.select({ kind: osuEntries.kind, achieved: osuEntries.achieved })
.from(osuEntries)
.where(eq(osuEntries.userId, locals.user.id))
: []
]);
return {
pokemon,
callOfDuty,
codOverall: combinedProgress(callOfDuty.map((g) => g.overall)),
osuGoals: {
value: osu.filter((e) => e.kind === 'goal' && e.achieved).length,
total: osu.filter((e) => e.kind === 'goal').length
}
};
};

View File

@@ -1,222 +1 @@
<script lang="ts"> <!-- Redirects to /trackers/games (see +page.server.ts). -->
import { resolve } from '$app/paths';
import Gamepad2 from 'lucide-svelte/icons/gamepad-2';
import SpottingSwitcher from '#lib/components/SpottingSwitcher.svelte';
import { gameTrackers } from '#lib/projects.js';
import GameLogo from '#lib/game-tracker/GameLogo.svelte';
import ProgressMeter from '#lib/game-tracker/ProgressMeter.svelte';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
</script>
<div class="tracker-library mx-auto flex w-full max-w-[1600px] flex-col px-4 py-5 sm:px-6">
<div class="mb-5 flex flex-wrap items-end justify-between gap-2">
<div>
<p class="text-xs tracking-[.18em] text-muted-foreground uppercase">Your next session</p>
<h1 class="mt-1 text-3xl font-semibold tracking-tight">Game tracker</h1>
</div>
{#if !data.user}<a
class="text-sm text-muted-foreground underline"
href={resolve('login?returnTo=/projects/game-tracker')}>Sign in to save progress</a
>{:else}<a
class="text-xs text-muted-foreground underline"
href={resolve('projects/game-tracker/credits')}>Artwork credits</a
>{/if}
</div>
<!-- Every game I play: one page each (same chips as the header flyout and tiles). -->
<SpottingSwitcher
items={gameTrackers}
home="/projects/game-tracker"
label="games"
homeIcon={Gamepad2}
class="mb-4"
/>
<div
class="mb-4 grid grid-cols-2 gap-3 rounded-xl border bg-card p-3 lg:grid-cols-4"
aria-label="Your completion dashboard"
>
<ProgressMeter
label="National collection"
progress={data.pokemon.caught}
detail={`${data.pokemon.caught.value} unique species caught`}
hint="Unique species caught across the Pokémon games in your rules, out of the full species catalog."
/>
<ProgressMeter
label="Species seen at least once"
progress={data.pokemon.seen}
color="sky"
detail={`${data.pokemon.seen.value} species discovered`}
/>
<ProgressMeter
label="Pokémon games completed"
progress={data.pokemon.completed}
color="amber"
hint="Games marked story complete, or with all default story milestones checked."
/>
<ProgressMeter
label="Call of Duty overall"
progress={data.codOverall}
color="violet"
detail="Mastery · achievements · challenges"
/>
</div>
<div class="series-grid grid min-h-0 flex-1 gap-4">
<section
class="series-column flex min-h-0 min-w-0 flex-col rounded-2xl border bg-card p-3"
aria-labelledby="pokemon-series"
>
<a
href={resolve('projects/game-tracker/pokemon')}
class="series-heading mb-3 flex items-center gap-3 rounded-xl bg-amber-500/10 p-3 transition-colors hover:bg-amber-500/20"
>
<GameLogo slug="pokemon" name="Pokémon" class="h-12 w-24" />
<div class="min-w-0 flex-1">
<h2 id="pokemon-series" class="font-semibold">Pokémon</h2>
<p class="text-xs text-muted-foreground">Open your adventure journal →</p>
<p class="mt-1 text-xs text-muted-foreground">
{data.pokemon.caught.value} / {data.pokemon.caught.total} species
</p>
</div>
</a>
<div class="mb-3 space-y-2 rounded-lg bg-muted/20 p-2">
<ProgressMeter
label="Across every adventure"
progress={data.pokemon.overall}
color="amber"
compact
hint="Equal weight per game; collection and story stages each contribute."
/>
<ProgressMeter
label="Every game’s collection"
progress={data.pokemon.collection}
compact
hint="Each confirmed species/game pairing counts once, including trades and events."
/>
</div>
{#if data.pokemon.games.length}
<div class="collection-grid grid grid-cols-2 gap-2">
{#each data.pokemon.games as game (game.id)}
<a
class="game-row flex min-h-0 min-w-0 items-center gap-2 rounded-lg border px-2 py-1.5 transition-colors hover:border-amber-500/50 hover:bg-amber-500/5"
href={resolve(`projects/game-tracker/pokemon?game=${game.slug ?? game.id}`)}
title={`${game.name} · ${game.releaseYear}`}
>
<GameLogo
slug={`pokemon/${game.slug}`}
name={game.name}
class="game-cover h-10 w-10"
/>
<div class="min-w-0 flex-1">
<p class="game-name text-xs leading-tight font-medium">{game.name}</p>
<p class="game-year mt-0.5 text-[10px] text-muted-foreground">
{game.releaseYear}{game.completed ? ' · ✓ story' : ''}
</p>
<ProgressMeter
label="Game progress"
progress={game.overall}
color="amber"
compact
/>
</div>
</a>
{/each}
</div>
{:else}<a
class="rounded-xl border border-dashed p-5 text-sm text-muted-foreground hover:text-foreground"
href={resolve('projects/game-tracker/pokemon')}
>Choose your games in “set rules” to build your collection here.</a
>{/if}
</section>
<section
class="series-column flex min-h-0 min-w-0 flex-col rounded-2xl border bg-card p-3"
aria-labelledby="cod-series"
>
<div class="series-heading mb-3 flex items-center gap-3 rounded-xl bg-zinc-500/10 p-3">
<GameLogo slug="call-of-duty" name="Call of Duty" class="h-12 w-24" />
<div>
<h2 id="cod-series" class="font-semibold">Call of Duty</h2>
<p class="text-xs text-muted-foreground">
{data.callOfDuty.length} campaigns of progress
</p>
</div>
</div>
<div class="mb-3 px-1">
<ProgressMeter label="COD completion" progress={data.codOverall} color="violet" compact />
</div>
<div class="cod-rows grid min-h-0 gap-1.5">
{#each data.callOfDuty as game (game.slug)}
<a
href={resolve(`projects/game-tracker/call-of-duty/${game.slug}`)}
class="game-row flex min-h-0 items-center gap-3 rounded-lg border p-2 transition-colors hover:border-primary/40 hover:bg-muted/50"
>
<GameLogo slug={`cod/${game.slug}`} name={game.name} class="h-11 w-24" />
<div class="min-w-0 flex-1">
<p class="text-sm leading-tight font-medium">{game.name}</p>
<p class="mt-1 text-[11px] text-muted-foreground">
{game.releaseYear}<span class="ml-2"
>{game.achievements.value}/{game.achievements.total} achievements</span
>
</p>
<ProgressMeter
label={`${game.name} progress`}
progress={game.overall}
color="violet"
compact
/>
</div>
</a>
{/each}
</div>
</section>
<section
class="series-column flex min-w-0 flex-col rounded-2xl border bg-card p-3"
aria-labelledby="osu-series"
>
<div class="series-heading mb-3 flex items-center gap-3 rounded-xl bg-pink-500/10 p-3">
<GameLogo slug="osu" name="osu!" class="h-12 w-12" />
<div>
<h2 id="osu-series" class="font-semibold">osu!</h2>
<p class="text-xs text-muted-foreground">One more attempt</p>
</div>
</div>
<a
href={resolve('projects/game-tracker/osu')}
class="rounded-xl border p-4 transition-colors hover:border-pink-500/50 hover:bg-pink-500/5"
><div class="flex items-center gap-3">
<GameLogo slug="osu" name="osu!" class="h-14 w-14" />
<div>
<p class="font-semibold">osu!</p>
<p class="text-xs text-muted-foreground">2007</p>
</div>
</div>
<p class="mt-4 text-sm">Your training room →</p>
<p class="mt-1 text-xs leading-relaxed text-muted-foreground">
Scores, dream FCs, stamina and speed. Build your next personal best.
</p>
<div class="mt-4">
<ProgressMeter label="osu! goals achieved" progress={data.osuGoals} color="pink" />
</div></a
>
</section>
</div>
</div>
<style>
.series-grid {
grid-template-columns: minmax(0, 1.5fr) minmax(0, 1.2fr) minmax(0, 0.8fr);
align-items: start;
}
.game-row {
min-height: 64px;
}
@media (max-width: 1100px) {
.series-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 639px) {
.series-grid {
grid-template-columns: minmax(0, 1fr);
}
}
</style>

View File

@@ -239,7 +239,7 @@
<div class="cod-companion"> <div class="cod-companion">
<div class="companion-header"> <div class="companion-header">
<a href={resolve('projects/game-tracker')} class="back-link">← game tracker</a> <a href={resolve('/trackers/games')} class="back-link">← game tracker</a>
<GameLogo <GameLogo
slug={`cod/${data.game.slug}`} slug={`cod/${data.game.slug}`}
name={data.game.shortName} name={data.game.shortName}

View File

@@ -1,17 +1,5 @@
import weaponArt from '../../../../../data/cod-art.json'; import { redirect } from '@sveltejs/kit';
import rankArt from '../../../../../data/cod-rank-art.json'; import type { PageServerLoad } from './$types';
import images from '../../../../../data/game-images.json';
import maps from '../../../../../data/pokemon-map-art.json'; // Moved with the games dashboard.
export function load() { export const load: PageServerLoad = () => redirect(301, '/trackers/games/credits');
const sources = new Map<string, { source: string; credit: string; count: number }>();
for (const image of [...images, ...weaponArt, ...rankArt]) {
const existing = sources.get(image.source);
if (existing) existing.count++;
else sources.set(image.source, { source: image.source, credit: image.credit, count: 1 });
}
return {
sources: [...sources.values()],
maps: Object.values(maps).map(({ name, source, credit }) => ({ name, source, credit })),
total: images.length + weaponArt.length + rankArt.length
};
}

View File

@@ -1,61 +1 @@
<script lang="ts"> <!-- Redirects to /trackers/games/credits (see +page.server.ts). -->
import { resolve } from '$app/paths';
import TrackerIcon from '#lib/components/TrackerIcon.svelte';
import { gameLogos } from '#lib/game-logos.js';
import { gameTrackers } from '#lib/projects.js';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
</script>
<!-- Download manifest links point to reviewed external image sources. -->
<!-- eslint-disable svelte/no-navigation-without-resolve -->
<div class="mx-auto max-w-4xl px-4 py-10 sm:px-6">
<a href={resolve('projects/game-tracker')} class="text-sm text-muted-foreground hover:underline"
>← Game tracker</a
>
<h1 class="mt-6 text-3xl font-semibold">Artwork credits</h1>
<p class="mt-3 text-muted-foreground">
Game covers, logos, sprites, maps and screenshots belong to their respective owners. The tracker
stores WebP copies for consistent, reliable display. Map imagery is shared across games in the
same region; later-region landmark positions are approximate.
</p>
<h2 class="mt-8 text-xl font-semibold">Game logos</h2>
<ul class="mt-4 grid gap-3 sm:grid-cols-2">
{#each gameTrackers as game (game.href)}
{@const logo = gameLogos[game.icon ?? game.name]}
{#if logo}
<li class="flex items-center gap-3">
<TrackerIcon name={game.icon ?? game.name} class="size-8" />
<div class="min-w-0">
<p class="text-sm font-medium">{game.name}</p>
<a href={logo.source} target="_blank" rel="noreferrer" class="text-xs underline"
>{logo.credit}</a
>
</div>
</li>
{/if}
{/each}
</ul>
<h2 class="mt-8 text-xl font-semibold">Regional maps</h2>
<ul class="mt-4 space-y-3">
{#each data.maps as map (map.name)}<li>
<a href={map.source} target="_blank" rel="noreferrer" class="font-medium underline"
>{map.name}</a
>
<p class="text-xs text-muted-foreground">{map.credit}</p>
</li>{/each}
</ul>
<h2 class="mt-8 text-xl font-semibold">Covers, sprites and other artwork</h2>
<p class="mt-2 text-sm text-muted-foreground">
{data.total.toLocaleString()} downloaded image records. Box art is archived by Libretro and Serebii;
Pokémon sprites and official artwork are archived by PokéAPI. COD covers and achievement icons come
from Steam.
</p>
<ul class="mt-4 space-y-3">
{#each data.sources as item (item.source)}<li>
<a href={item.source} target="_blank" rel="noreferrer" class="text-sm underline"
>{item.credit}</a
><span class="ml-2 text-xs text-muted-foreground">{item.count} images</span>
</li>{/each}
</ul>
</div>

View File

@@ -71,9 +71,8 @@
</script> </script>
<div class="mx-auto max-w-6xl px-3 py-4 sm:px-5"> <div class="mx-auto max-w-6xl px-3 py-4 sm:px-5">
<a <a href={resolve('/trackers/games')} class="text-sm text-muted-foreground hover:text-foreground"
href={resolve('projects/game-tracker')} >← game tracker</a
class="text-sm text-muted-foreground hover:text-foreground">← game tracker</a
> >
<div class="mt-3 flex flex-wrap items-center gap-3"> <div class="mt-3 flex flex-wrap items-center gap-3">
<GameLogo slug="osu" name="osu!" class="h-12 w-12 rounded-full bg-pink-500/10 text-pink-500" /> <GameLogo slug="osu" name="osu!" class="h-12 w-12 rounded-full bg-pink-500/10 text-pink-500" />

View File

@@ -48,9 +48,8 @@
class="h-12 w-12" class="h-12 w-12"
/>{/if} />{/if}
<div> <div>
<a <a href={resolve('/trackers/games')} class="text-xs text-muted-foreground hover:underline"
href={resolve('projects/game-tracker')} >← Game tracker</a
class="text-xs text-muted-foreground hover:underline">← Game tracker</a
> >
<h1 class="text-xl font-semibold"> <h1 class="text-xl font-semibold">
Pokémon journal <span class="text-sm font-normal text-muted-foreground" Pokémon journal <span class="text-sm font-normal text-muted-foreground"

View File

@@ -28,7 +28,7 @@ const staticPaths = [
'/trackers/wildlife/groups', '/trackers/wildlife/groups',
'/trackers/music', '/trackers/music',
'/trackers/pc', '/trackers/pc',
'/projects/game-tracker', '/trackers/games',
...gameTrackers.map((g) => g.href), ...gameTrackers.map((g) => g.href),
'/projects/game-tracker/pokemon', '/projects/game-tracker/pokemon',
'/projects/game-tracker/osu', '/projects/game-tracker/osu',

View File

@@ -41,7 +41,7 @@
// Same list, order and icons as the header Trackers dropdown (owner-only ones only when // Same list, order and icons as the header Trackers dropdown (owner-only ones only when
// the header shows them). Spotting is a group tile with one-click chips per sub-tracker. // the header shows them). Spotting is a group tile with one-click chips per sub-tracker.
const trackers = $derived(visibleTrackers(data.isOwner)); const trackers = $derived(visibleTrackers(data.isOwner, data.inactiveGames));
// Search-only shortcuts that aren't trackers of their own. // Search-only shortcuts that aren't trackers of their own.
const extraHits = [ const extraHits = [
{ name: 'Weigh-in', href: '/trackers/health#body' }, { name: 'Weigh-in', href: '/trackers/health#body' },
@@ -249,6 +249,20 @@
? (t.children ?? []).filter((c) => c.featured) ? (t.children ?? []).filter((c) => c.featured)
: (t.children ?? []); : (t.children ?? []);
const hiddenChildren = (t: Group) => (t.children?.length ?? 0) - tileChildren(t).length; const hiddenChildren = (t: Group) => (t.children?.length ?? 0) - tileChildren(t).length;
// Games split like the header's Trackers dropdown: "active" (my pick, always shown) and
// "non-active" (behind its own toggle, shown while filtering).
type Child = NonNullable<Group['children']>[number];
const splitGroup = (t: Group) => (t.children ?? []).some((c) => c.inactive);
const chipSections = (t: Group): { label: string; items: readonly Child[] }[] => {
const all = t.children ?? [];
if (!splitGroup(t)) return [{ label: '', items: tileChildren(t) }];
const off = all.filter((c) => c.inactive);
return [
{ label: 'active', items: all.filter((c) => !c.inactive) },
{ label: 'non-active', items: filtering || expandedGroups[t.name] ? off : [] }
];
};
const inactiveCount = (t: Group) => (t.children ?? []).filter((c) => c.inactive).length;
// The links filter (f) also narrows the trackers; a group stays if it or a chip matches. // The links filter (f) also narrows the trackers; a group stays if it or a chip matches.
const shownTrackers = $derived.by(() => { const shownTrackers = $derived.by(() => {
const q = linkFilter.trim().toLowerCase(); const q = linkFilter.trim().toLowerCase();
@@ -571,12 +585,35 @@
<span class="tname">{t.name}</span>{#if t.locked}<PrivateLock tight />{/if} <span class="tname">{t.name}</span>{#if t.locked}<PrivateLock tight />{/if}
</a> </a>
<span class="tchips"> <span class="tchips">
{#each tileChildren(t) as c (c.name)} {#each chipSections(t) as section (section.label)}
<a class="tchip" href={c.href} title={c.subtitle} {#if section.label === 'non-active' && !filtering}
<button
type="button"
class="tlabel toggle"
aria-expanded={!!expandedGroups[t.name]}
onclick={() =>
(expandedGroups = {
...expandedGroups,
[t.name]: !expandedGroups[t.name]
})}
>non-active <span>{inactiveCount(t)}</span>{expandedGroups[t.name]
? ' ▾'
: ' ▸'}</button
>
{:else if section.label && section.items.length}
<span class="tlabel">{section.label} <span>{section.items.length}</span></span
>
{/if}
{#each section.items as c (c.name)}
<a
class={['tchip', c.inactive && 'inactive']}
href={c.href}
title={c.subtitle}
><TrackerIcon name={c.icon ?? c.name} class="t-icon size-5" />{c.name}</a ><TrackerIcon name={c.icon ?? c.name} class="t-icon size-5" />{c.name}</a
> >
{/each} {/each}
{#if !filtering && (hiddenChildren(t) > 0 || expandedGroups[t.name])} {/each}
{#if !filtering && !splitGroup(t) && (hiddenChildren(t) > 0 || expandedGroups[t.name])}
<button <button
type="button" type="button"
class="tchip more" class="tchip more"
@@ -1670,6 +1707,38 @@
.stretched:hover { .stretched:hover {
text-decoration: none; text-decoration: none;
} }
.tlabel {
flex-basis: 100%;
display: flex;
align-items: baseline;
gap: 0.35rem;
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--site-muted);
background: none;
border: 0;
padding: 0;
text-align: left;
}
.tlabel span {
letter-spacing: 0;
opacity: 0.7;
}
.tlabel:not(:first-child) {
margin-top: 0.3rem;
}
button.tlabel {
cursor: pointer;
}
button.tlabel:hover {
color: inherit;
}
.tchip.inactive :global(.t-icon) {
filter: grayscale(0.6);
opacity: 0.75;
}
.tchips { .tchips {
position: relative; position: relative;
z-index: 1; z-index: 1;

View File

@@ -10,5 +10,5 @@
<p class="mb-10 text-[var(--site-muted)]"> <p class="mb-10 text-[var(--site-muted)]">
Personal data I keep track of: games, money, houses, books, prices, animals and lists. Personal data I keep track of: games, money, houses, books, prices, animals and lists.
</p> </p>
<CatalogGrid items={visibleTrackers(data.isOwner)} variant="tracker" wide /> <CatalogGrid items={visibleTrackers(data.isOwner, data.inactiveGames)} variant="tracker" wide />
</div> </div>

View File

@@ -1,15 +1,20 @@
<script lang="ts"> <script lang="ts">
import Gamepad2 from 'lucide-svelte/icons/gamepad-2'; import Gamepad2 from 'lucide-svelte/icons/gamepad-2';
import { page } from '$app/state';
import SpottingSwitcher from '#lib/components/SpottingSwitcher.svelte'; import SpottingSwitcher from '#lib/components/SpottingSwitcher.svelte';
import { gameTrackers } from '#lib/projects.js'; import { activeGames, sortedGames } from '#lib/projects.js';
let { children } = $props(); // The game chips above every /trackers/games page: the games I play now (games.library.active),
// plus the game whose page this is when it is non-active.
let { data, children } = $props();
const items = $derived.by(() => {
const active = activeGames(data.inactiveGames);
const current = sortedGames(data.inactiveGames).find(
(g) => g.inactive && page.url.pathname === g.href
);
return current ? [...active, current] : active;
});
</script> </script>
<SpottingSwitcher <SpottingSwitcher {items} home="/trackers/games" label="games" homeIcon={Gamepad2} />
items={gameTrackers}
home="/projects/game-tracker"
label="games"
homeIcon={Gamepad2}
/>
{@render children()} {@render children()}

View File

@@ -1,5 +1,220 @@
import { redirect } from '@sveltejs/kit'; import { fail } from '@sveltejs/kit';
import type { PageServerLoad } from './$types'; import { asc, eq } from 'drizzle-orm';
import { db } from '#lib/server/db/index.js';
import {
trackerGames,
trackerItems,
trackerProgress,
trackerWeaponRanks,
trackerRanks,
trackerChallenges,
trackerChallengeProgress,
osuEntries,
gameLibrary
} from '#lib/server/db/schema/index.js';
import { isOwner, requireOwner } from '#lib/server/owner.js';
import { loadInactiveGames, setGameActive } from '#lib/server/games/active.js';
import { pokemonEntryFor } from '#lib/projects.js';
import { loadPokemonProgress } from '#lib/server/tracker-progress.js';
import { challengeProgress, combinedProgress, sumProgress } from '#lib/game-tracker/progress.js';
import { gameRanksProgress, hasReachedMaxLevel } from '#lib/game-tracker/rank-progress.js';
import { summarizeGame } from '#lib/game-tracker/mastery.js';
import { chronologicalPokemon, pokemonRelease } from '#lib/game-tracker/game-art.js';
import type { Actions, PageServerLoad } from './$types';
// The games group's landing page is the game tracker; each game lives at /trackers/games/<slug>. // The games dashboard (moved here from /projects/game-tracker): only the games I play now
export const load: PageServerLoad = () => redirect(307, '/projects/game-tracker'); // (games.library.active, my pick). The owner switches games between active and non-active at
// the bottom; non-active games keep all their data and their pages.
async function pokemonSummary(userId: string | undefined) {
const progress = await loadPokemonProgress(userId);
return {
...progress,
games: chronologicalPokemon(progress.configured ? progress.games : []).map((g) => ({
...g,
releaseYear: pokemonRelease(g.slug).slice(0, 4)
}))
};
}
async function callOfDutySummary(userId: string | undefined) {
const [gamesList, items, progress, ranks, playerRanks, challenges, challengeRows] =
await Promise.all([
db
.select()
.from(trackerGames)
.where(eq(trackerGames.franchise, 'call-of-duty'))
.orderBy(asc(trackerGames.releaseYear), 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([]),
userId
? db
.select({
itemId: trackerWeaponRanks.itemId,
prestige: trackerWeaponRanks.prestige,
level: trackerWeaponRanks.level
})
.from(trackerWeaponRanks)
.where(eq(trackerWeaponRanks.userId, userId))
: Promise.resolve([]),
userId
? db
.select({
gameId: trackerRanks.gameId,
level: trackerRanks.level,
prestige: trackerRanks.prestige
})
.from(trackerRanks)
.where(eq(trackerRanks.userId, userId))
: Promise.resolve([]),
db
.select({
id: trackerChallenges.id,
gameId: trackerChallenges.gameId,
target: trackerChallenges.target
})
.from(trackerChallenges),
userId
? db
.select({
challengeId: trackerChallengeProgress.challengeId,
current: trackerChallengeProgress.current,
done: trackerChallengeProgress.done
})
.from(trackerChallengeProgress)
.where(eq(trackerChallengeProgress.userId, userId))
: []
]);
const done = new Set(progress.map((row) => `${row.itemId}:${row.milestone}`));
for (const rank of ranks) if (rank.prestige > 0) done.add(`${rank.itemId}:maxLevel`);
const byGame = Map.groupBy(items, (item) => item.gameId);
return gamesList.map((game) => {
const list = byGame.get(game.id) ?? [];
const weapons = list.filter((i) => i.kind === 'weapon');
const rankMap = new Map(ranks.map((r) => [r.itemId, r]));
for (const weapon of weapons)
if (hasReachedMaxLevel(game.rules, weapon, rankMap.get(weapon.id)))
done.add(`${weapon.id}:maxLevel`);
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}`)
);
const weaponProgress = sumProgress(summary.milestones);
const challengesProgress = challengeProgress(
challenges.filter((c) => c.gameId === game.id),
challengeRows
);
const ranksProgress = gameRanksProgress(
game.rules,
weapons,
rankMap,
playerRanks.find((r) => r.gameId === game.id)
);
return {
overall: combinedProgress([
weaponProgress,
ranksProgress,
summary.achievements,
challengesProgress
]),
weaponProgress,
challengesProgress,
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 (event) => {
const { locals } = event;
const owner = isOwner(event);
const [pokemon, callOfDuty, osu, inactiveGames, library] = await Promise.all([
pokemonSummary(locals.user?.id),
callOfDutySummary(locals.user?.id),
locals.user
? db
.select({ kind: osuEntries.kind, achieved: osuEntries.achieved })
.from(osuEntries)
.where(eq(osuEntries.userId, locals.user.id))
: [],
loadInactiveGames(),
owner
? db
.select({
slug: gameLibrary.slug,
name: gameLibrary.name,
active: gameLibrary.active
})
.from(gameLibrary)
.orderBy(asc(gameLibrary.sortOrder))
: []
]);
const off = new Set(inactiveGames);
// Pokémon journal games belong to a generation entry (e.g. Platinum to Diamond, Pearl &
// Platinum); games without one always show.
const activePokemon = pokemon.games.filter((g) => {
const entry = pokemonEntryFor(g.slug);
return !entry || !off.has(entry);
});
return {
pokemon: { ...pokemon, games: activePokemon },
callOfDuty: callOfDuty.filter((g) => !off.has(g.slug)),
codOverall: combinedProgress(callOfDuty.map((g) => g.overall)),
showOsu: !off.has('osu'),
showPokemon: library.length
? library.some((g) => g.slug.startsWith('pokemon-') && g.active)
: true,
osuGoals: {
value: osu.filter((e) => e.kind === 'goal' && e.achieved).length,
total: osu.filter((e) => e.kind === 'goal').length
},
/** Owner only: every game with its active flag, for the active/non-active switches. */
library
};
};
export const actions: Actions = {
/** Owner only: move one game between active and non-active (nothing is deleted). */
setActive: async (event) => {
requireOwner(event);
const form = await event.request.formData();
const slug = String(form.get('slug') ?? '');
const active = form.get('active') === 'true';
if (!/^[a-z0-9-]+$/.test(slug)) return fail(400, { message: 'Unknown game' });
if (!(await setGameActive(slug, active))) return fail(404, { message: 'Unknown game' });
return { slug, active };
}
};

View File

@@ -1 +1,384 @@
<!-- Redirects to /projects/game-tracker (see +page.server.ts). --> <script lang="ts">
import { resolve } from '$app/paths';
import { enhance } from '$app/forms';
import Power from 'lucide-svelte/icons/power';
import PowerOff from 'lucide-svelte/icons/power-off';
import TrackerIcon from '#lib/components/TrackerIcon.svelte';
import { gameTrackers, gameSlug } from '#lib/projects.js';
import GameLogo from '#lib/game-tracker/GameLogo.svelte';
import ProgressMeter from '#lib/game-tracker/ProgressMeter.svelte';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
// Owner: every game, split into what I play now and the rest; one click switches a game.
const nav = new Map(gameTrackers.map((g) => [gameSlug(g), g]));
const activeList = $derived(data.library.filter((g) => g.active));
const inactiveList = $derived(data.library.filter((g) => !g.active));
let busy = $state<string | null>(null);
let showInactive = $state(true);
</script>
{#snippet switchRow(game: { slug: string; name: string; active: boolean })}
{@const item = nav.get(game.slug)}
<li>
<form
method="POST"
action="?/setActive"
use:enhance={() => {
busy = game.slug;
return async ({ update }) => {
await update({ reset: false });
busy = null;
};
}}
class={['switch-row', !game.active && 'off']}
>
<input type="hidden" name="slug" value={game.slug} />
<input type="hidden" name="active" value={game.active ? 'false' : 'true'} />
<a href={resolve(`/trackers/games/${game.slug}`)} class="switch-name" title={game.name}
><TrackerIcon name={item?.icon ?? `game-${game.slug}`} class="switch-icon size-7" /><span
class="truncate">{item?.name ?? game.name}</span
></a
>
<button
type="submit"
class="switch-btn"
disabled={busy === game.slug}
aria-label={game.active ? `Make ${game.name} non-active` : `Make ${game.name} active`}
title={game.active ? 'Make non-active' : 'Make active'}
>{#if game.active}<PowerOff size={14} aria-hidden="true" />{:else}<Power
size={14}
aria-hidden="true"
/>{/if}<span>{game.active ? 'non-active' : 'activate'}</span></button
>
</form>
</li>
{/snippet}
<div class="tracker-library mx-auto flex w-full max-w-[1600px] flex-col px-4 py-5 sm:px-6">
<div class="mb-5 flex flex-wrap items-end justify-between gap-2">
<div>
<p class="text-xs tracking-[.18em] text-muted-foreground uppercase">Your next session</p>
<h1 class="mt-1 text-3xl font-semibold tracking-tight">Game tracker</h1>
</div>
{#if !data.user}<a
class="text-sm text-muted-foreground underline"
href={resolve('/login?returnTo=/trackers/games')}>Sign in to save progress</a
>{:else}<a
class="text-xs text-muted-foreground underline"
href={resolve('/trackers/games/credits')}>Artwork credits</a
>{/if}
</div>
<div
class="mb-4 grid grid-cols-2 gap-3 rounded-xl border bg-card p-3 lg:grid-cols-4"
aria-label="Your completion dashboard"
>
<ProgressMeter
label="National collection"
progress={data.pokemon.caught}
detail={`${data.pokemon.caught.value} unique species caught`}
hint="Unique species caught across the Pokémon games in your rules, out of the full species catalog."
/>
<ProgressMeter
label="Species seen at least once"
progress={data.pokemon.seen}
color="sky"
detail={`${data.pokemon.seen.value} species discovered`}
/>
<ProgressMeter
label="Pokémon games completed"
progress={data.pokemon.completed}
color="amber"
hint="Games marked story complete, or with all default story milestones checked."
/>
<ProgressMeter
label="Call of Duty overall"
progress={data.codOverall}
color="violet"
detail="Mastery · achievements · challenges"
/>
</div>
<div class="series-grid grid min-h-0 flex-1 gap-4">
{#if data.showPokemon}<section
class="series-column flex min-h-0 min-w-0 flex-col rounded-2xl border bg-card p-3"
aria-labelledby="pokemon-series"
>
<a
href={resolve('projects/game-tracker/pokemon')}
class="series-heading mb-3 flex items-center gap-3 rounded-xl bg-amber-500/10 p-3 transition-colors hover:bg-amber-500/20"
>
<GameLogo slug="pokemon" name="Pokémon" class="h-12 w-24" />
<div class="min-w-0 flex-1">
<h2 id="pokemon-series" class="font-semibold">Pokémon</h2>
<p class="text-xs text-muted-foreground">Open your adventure journal →</p>
<p class="mt-1 text-xs text-muted-foreground">
{data.pokemon.caught.value} / {data.pokemon.caught.total} species
</p>
</div>
</a>
<div class="mb-3 space-y-2 rounded-lg bg-muted/20 p-2">
<ProgressMeter
label="Across every adventure"
progress={data.pokemon.overall}
color="amber"
compact
hint="Equal weight per game; collection and story stages each contribute."
/>
<ProgressMeter
label="Every game’s collection"
progress={data.pokemon.collection}
compact
hint="Each confirmed species/game pairing counts once, including trades and events."
/>
</div>
{#if data.pokemon.games.length}
<div class="collection-grid grid grid-cols-2 gap-2">
{#each data.pokemon.games as game (game.id)}
<a
class="game-row flex min-h-0 min-w-0 items-center gap-2 rounded-lg border px-2 py-1.5 transition-colors hover:border-amber-500/50 hover:bg-amber-500/5"
href={resolve(`projects/game-tracker/pokemon?game=${game.slug ?? game.id}`)}
title={`${game.name} · ${game.releaseYear}`}
>
<GameLogo
slug={`pokemon/${game.slug}`}
name={game.name}
class="game-cover h-10 w-10"
/>
<div class="min-w-0 flex-1">
<p class="game-name text-xs leading-tight font-medium">{game.name}</p>
<p class="game-year mt-0.5 text-[10px] text-muted-foreground">
{game.releaseYear}{game.completed ? ' · ✓ story' : ''}
</p>
<ProgressMeter
label="Game progress"
progress={game.overall}
color="amber"
compact
/>
</div>
</a>
{/each}
</div>
{:else}<a
class="rounded-xl border border-dashed p-5 text-sm text-muted-foreground hover:text-foreground"
href={resolve('projects/game-tracker/pokemon')}
>Choose your games in “set rules” to build your collection here.</a
>{/if}
</section>
{/if}
{#if data.callOfDuty.length}<section
class="series-column flex min-h-0 min-w-0 flex-col rounded-2xl border bg-card p-3"
aria-labelledby="cod-series"
>
<div class="series-heading mb-3 flex items-center gap-3 rounded-xl bg-zinc-500/10 p-3">
<GameLogo slug="call-of-duty" name="Call of Duty" class="h-12 w-24" />
<div>
<h2 id="cod-series" class="font-semibold">Call of Duty</h2>
<p class="text-xs text-muted-foreground">
{data.callOfDuty.length} campaigns of progress
</p>
</div>
</div>
<div class="mb-3 px-1">
<ProgressMeter label="COD completion" progress={data.codOverall} color="violet" compact />
</div>
<div class="cod-rows grid min-h-0 gap-1.5">
{#each data.callOfDuty as game (game.slug)}
<a
href={resolve(`projects/game-tracker/call-of-duty/${game.slug}`)}
class="game-row flex min-h-0 items-center gap-3 rounded-lg border p-2 transition-colors hover:border-primary/40 hover:bg-muted/50"
>
<GameLogo slug={`cod/${game.slug}`} name={game.name} class="h-11 w-24" />
<div class="min-w-0 flex-1">
<p class="text-sm leading-tight font-medium">{game.name}</p>
<p class="mt-1 text-[11px] text-muted-foreground">
{game.releaseYear}<span class="ml-2"
>{game.achievements.value}/{game.achievements.total} achievements</span
>
</p>
<ProgressMeter
label={`${game.name} progress`}
progress={game.overall}
color="violet"
compact
/>
</div>
</a>
{/each}
</div>
</section>
{/if}
{#if data.showOsu}<section
class="series-column flex min-w-0 flex-col rounded-2xl border bg-card p-3"
aria-labelledby="osu-series"
>
<div class="series-heading mb-3 flex items-center gap-3 rounded-xl bg-pink-500/10 p-3">
<GameLogo slug="osu" name="osu!" class="h-12 w-12" />
<div>
<h2 id="osu-series" class="font-semibold">osu!</h2>
<p class="text-xs text-muted-foreground">One more attempt</p>
</div>
</div>
<a
href={resolve('projects/game-tracker/osu')}
class="rounded-xl border p-4 transition-colors hover:border-pink-500/50 hover:bg-pink-500/5"
><div class="flex items-center gap-3">
<GameLogo slug="osu" name="osu!" class="h-14 w-14" />
<div>
<p class="font-semibold">osu!</p>
<p class="text-xs text-muted-foreground">2007</p>
</div>
</div>
<p class="mt-4 text-sm">Your training room →</p>
<p class="mt-1 text-xs leading-relaxed text-muted-foreground">
Scores, dream FCs, stamina and speed. Build your next personal best.
</p>
<div class="mt-4">
<ProgressMeter label="osu! goals achieved" progress={data.osuGoals} color="pink" />
</div></a
>
</section>{/if}
</div>
{#if data.library.length}
<!-- Owner only: which games I play now. Non-active games keep their data and pages; the
header and the tiles list them under "non-active". -->
<section
id="manage"
class="manage mt-6 grid gap-3 lg:grid-cols-2"
aria-label="Active and non-active games"
>
<div class="manage-col rounded-2xl border bg-card p-3">
<h2 class="manage-title">active <span>{activeList.length}</span></h2>
<p class="manage-hint">Shown here and first everywhere games are listed.</p>
<ul class="switch-list">
{#each activeList as game (game.slug)}{@render switchRow(game)}{/each}
</ul>
</div>
<div class="manage-col rounded-2xl border bg-card p-3">
<button
type="button"
class="manage-title w-full text-left"
aria-expanded={showInactive}
onclick={() => (showInactive = !showInactive)}
>non-active <span>{inactiveList.length}</span><span class="ml-auto"
>{showInactive ? '▾' : '▸'}</span
></button
>
<p class="manage-hint">Hidden from this dashboard; data and pages stay.</p>
{#if showInactive}
<ul class="switch-list">
{#each inactiveList as game (game.slug)}{@render switchRow(game)}{/each}
</ul>
{/if}
</div>
</section>
{/if}
</div>
<style>
.manage-title {
display: flex;
align-items: baseline;
gap: 0.45rem;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.manage-title span {
color: var(--muted-foreground);
letter-spacing: 0;
font-variant-numeric: tabular-nums;
}
.manage-hint {
margin: 0.15rem 0 0.6rem;
font-size: 0.75rem;
color: var(--muted-foreground);
}
.switch-list {
display: grid;
gap: 0.35rem;
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
}
.switch-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.3rem 0.35rem 0.3rem 0.3rem;
border: 1px solid color-mix(in oklab, var(--foreground) 9%, transparent);
border-radius: 0.7rem;
background: color-mix(in oklab, var(--foreground) 2%, transparent);
transition:
border-color 160ms ease,
background-color 160ms ease,
transform 160ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.switch-row:hover {
border-color: color-mix(in oklab, var(--foreground) 22%, transparent);
background: color-mix(in oklab, var(--foreground) 5%, transparent);
transform: translateY(-1px);
}
.switch-name {
display: flex;
min-width: 0;
flex: 1;
align-items: center;
gap: 0.55rem;
font-size: 0.85rem;
font-weight: 500;
}
.switch-row.off :global(.switch-icon) {
filter: grayscale(0.6);
opacity: 0.75;
}
.switch-row.off:hover :global(.switch-icon) {
filter: none;
opacity: 1;
}
.switch-btn {
display: inline-flex;
flex: none;
align-items: center;
gap: 0.3rem;
border: 1px solid color-mix(in oklab, var(--foreground) 14%, transparent);
border-radius: 999px;
padding: 0.2rem 0.6rem;
font-size: 0.72rem;
color: var(--muted-foreground);
cursor: pointer;
transition:
color 160ms ease,
background-color 160ms ease,
border-color 160ms ease;
}
.switch-btn:hover,
.switch-btn:focus-visible {
color: var(--foreground);
border-color: color-mix(in oklab, var(--foreground) 40%, transparent);
background: color-mix(in oklab, var(--foreground) 8%, transparent);
}
.switch-row.off .switch-btn {
color: var(--foreground);
}
.switch-btn:disabled {
opacity: 0.5;
cursor: progress;
}
.series-grid {
grid-template-columns: minmax(0, 1.5fr) minmax(0, 1.2fr) minmax(0, 0.8fr);
align-items: start;
}
.game-row {
min-height: 64px;
}
@media (max-width: 1100px) {
.series-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 639px) {
.series-grid {
grid-template-columns: minmax(0, 1fr);
}
}
</style>

View File

@@ -1,10 +1,11 @@
import { error, redirect } from '@sveltejs/kit'; import { error, fail, redirect } from '@sveltejs/kit';
import { eq, inArray } from 'drizzle-orm'; import { eq, inArray } from 'drizzle-orm';
import { db } from '#lib/server/db/index.js'; import { db } from '#lib/server/db/index.js';
import { gameLibrary, hardwareSensitivities } from '#lib/server/db/schema/index.js'; import { gameLibrary, hardwareSensitivities } from '#lib/server/db/schema/index.js';
import { isOwner } from '#lib/server/owner.js'; import { isOwner, requireOwner } from '#lib/server/owner.js';
import { setGameActive } from '#lib/server/games/active.js';
import { isAutoTracked, loadHistory } from '#lib/server/games/history.js'; import { isAutoTracked, loadHistory } from '#lib/server/games/history.js';
import type { PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
// One game from games.library, plus its mouse sensitivity (hardware.sensitivities, matched by // One game from games.library, plus its mouse sensitivity (hardware.sensitivities, matched by
// name, short name or alias) when there is one. CS2, PUBG and R6 also get their automatic // name, short name or alias) when there is one. CS2, PUBG and R6 also get their automatic
@@ -47,8 +48,10 @@ export const load: PageServerLoad = async (event) => {
currentRank: game.currentRank, currentRank: game.currentRank,
peakRank: game.peakRank, peakRank: game.peakRank,
rankLabel: game.rankLabel, rankLabel: game.rankLabel,
notes: game.notes notes: game.notes,
active: game.active
}, },
owner: isOwner(event),
sensitivity: sensitivity ?? null, sensitivity: sensitivity ?? null,
history: isAutoTracked(game.slug) ? await loadHistory(game.slug, isOwner(event)) : null, history: isAutoTracked(game.slug) ? await loadHistory(game.slug, isOwner(event)) : null,
seo: { seo: {
@@ -57,3 +60,15 @@ export const load: PageServerLoad = async (event) => {
} }
}; };
}; };
export const actions: Actions = {
/** Owner only: move this game between active and non-active (nothing is deleted). */
setActive: async (event) => {
requireOwner(event);
const form = await event.request.formData();
const active = form.get('active') === 'true';
if (!(await setGameActive(event.params.slug, active)))
return fail(404, { message: 'Unknown game' });
return { active };
}
};

View File

@@ -1,5 +1,11 @@
<script lang="ts"> <script lang="ts">
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { enhance } from '$app/forms';
import { goto } from '$app/navigation';
import Ellipsis from 'lucide-svelte/icons/ellipsis';
import Power from 'lucide-svelte/icons/power';
import PowerOff from 'lucide-svelte/icons/power-off';
import * as DropdownMenu from '#lib/components/ui/dropdown-menu/index.js';
import ArrowLeft from 'lucide-svelte/icons/arrow-left'; import ArrowLeft from 'lucide-svelte/icons/arrow-left';
import ArrowRight from 'lucide-svelte/icons/arrow-right'; import ArrowRight from 'lucide-svelte/icons/arrow-right';
import TrackerIcon from '#lib/components/TrackerIcon.svelte'; import TrackerIcon from '#lib/components/TrackerIcon.svelte';
@@ -19,6 +25,7 @@
// "~#40k": ranks are rough positions I gave, so round them. // "~#40k": ranks are rough positions I gave, so round them.
const rank = (n: number) => const rank = (n: number) =>
n >= 1000 ? `~#${Math.round(n / 1000).toLocaleString('en-GB')}k` : `#${n}`; n >= 1000 ? `~#${Math.round(n / 1000).toLocaleString('en-GB')}k` : `#${n}`;
let activeForm = $state<HTMLFormElement>();
const hasStats = $derived( const hasStats = $derived(
game.currentRank != null || game.peakRank != null || !!data.sensitivity game.currentRank != null || game.peakRank != null || !!data.sensitivity
); );
@@ -26,15 +33,43 @@
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16"> <div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
<a <a
href={resolve('/projects/game-tracker')} href={resolve('/trackers/games')}
class="mb-6 inline-flex items-center gap-1 text-sm text-[var(--site-muted)] hover:text-foreground" class="mb-6 inline-flex items-center gap-1 text-sm text-[var(--site-muted)] hover:text-foreground"
><ArrowLeft size={14} aria-hidden="true" /> games</a ><ArrowLeft size={14} aria-hidden="true" /> games</a
> >
<h1 class="mb-3 flex items-center gap-3 text-4xl font-semibold tracking-tight sm:text-5xl"> <h1 class="mb-3 flex items-center gap-3 text-4xl font-semibold tracking-tight sm:text-5xl">
<TrackerIcon name={nav?.icon ?? `game-${game.slug}`} class="size-10 sm:size-12" />{game.name} <TrackerIcon name={nav?.icon ?? `game-${game.slug}`} class="size-10 sm:size-12" />{game.name}
{#if data.owner}
<!-- Owner ⋯ menu: switch this game between active and non-active. -->
<form
method="POST"
action="?/setActive"
use:enhance={() =>
async ({ update }) =>
update({ reset: false })}
bind:this={activeForm}
hidden
>
<input type="hidden" name="active" value={game.active ? 'false' : 'true'} />
</form>
<DropdownMenu.Root>
<DropdownMenu.Trigger class="game-actions ml-auto" aria-label="Actions for {game.name}"
><Ellipsis size={18} aria-hidden="true" /></DropdownMenu.Trigger
>
<DropdownMenu.Content align="end" class="min-w-48">
<DropdownMenu.Item onSelect={() => activeForm?.requestSubmit()}>
{#if game.active}<PowerOff /> make non-active{:else}<Power /> make active{/if}
</DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => goto(`${resolve('/trackers/games')}#manage`)}
>all active and non-active games</DropdownMenu.Item
>
</DropdownMenu.Content>
</DropdownMenu.Root>
{/if}
</h1> </h1>
<div class="mb-10 flex flex-wrap items-center gap-1.5 text-sm"> <div class="mb-10 flex flex-wrap items-center gap-1.5 text-sm">
<span class={['badge', `status-${game.status}`]}>{status[game.status]}</span> <span class={['badge', `status-${game.status}`]}>{status[game.status]}</span>
{#if !game.active}<span class="badge inactive">non-active</span>{/if}
{#if game.platform}<span class="badge">{game.platform}</span>{/if} {#if game.platform}<span class="badge">{game.platform}</span>{/if}
{#if game.kind === 'puzzle'}<span class="badge">not a video game</span>{/if} {#if game.kind === 'puzzle'}<span class="badge">not a video game</span>{/if}
{#if nav?.subtitle && nav.subtitle !== game.name}<span class="ml-1 text-[var(--site-muted)]" {#if nav?.subtitle && nav.subtitle !== game.name}<span class="ml-1 text-[var(--site-muted)]"
@@ -111,6 +146,26 @@
color: var(--background); color: var(--background);
border-color: var(--foreground); border-color: var(--foreground);
} }
.badge.inactive {
border-style: dashed;
color: var(--site-muted);
}
:global(.game-actions) {
display: inline-grid;
place-items: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 999px;
color: var(--site-muted);
transition:
color 160ms ease,
background-color 160ms ease;
}
:global(.game-actions:hover),
:global(.game-actions:focus-visible) {
color: var(--foreground);
background: color-mix(in oklab, var(--foreground) 8%, transparent);
}
.status-active { .status-active {
border-color: color-mix(in oklab, var(--foreground) 40%, transparent); border-color: color-mix(in oklab, var(--foreground) 40%, transparent);
} }

View File

@@ -0,0 +1,17 @@
import weaponArt from '../../../../../data/cod-art.json';
import rankArt from '../../../../../data/cod-rank-art.json';
import images from '../../../../../data/game-images.json';
import maps from '../../../../../data/pokemon-map-art.json';
export function load() {
const sources = new Map<string, { source: string; credit: string; count: number }>();
for (const image of [...images, ...weaponArt, ...rankArt]) {
const existing = sources.get(image.source);
if (existing) existing.count++;
else sources.set(image.source, { source: image.source, credit: image.credit, count: 1 });
}
return {
sources: [...sources.values()],
maps: Object.values(maps).map(({ name, source, credit }) => ({ name, source, credit })),
total: images.length + weaponArt.length + rankArt.length
};
}

View File

@@ -0,0 +1,61 @@
<script lang="ts">
import { resolve } from '$app/paths';
import TrackerIcon from '#lib/components/TrackerIcon.svelte';
import { gameLogos } from '#lib/game-logos.js';
import { gameTrackers } from '#lib/projects.js';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
</script>
<!-- Download manifest links point to reviewed external image sources. -->
<!-- eslint-disable svelte/no-navigation-without-resolve -->
<div class="mx-auto max-w-4xl px-4 py-10 sm:px-6">
<a href={resolve('/trackers/games')} class="text-sm text-muted-foreground hover:underline"
>← Game tracker</a
>
<h1 class="mt-6 text-3xl font-semibold">Artwork credits</h1>
<p class="mt-3 text-muted-foreground">
Game covers, logos, sprites, maps and screenshots belong to their respective owners. The tracker
stores WebP copies for consistent, reliable display. Map imagery is shared across games in the
same region; later-region landmark positions are approximate.
</p>
<h2 class="mt-8 text-xl font-semibold">Game logos</h2>
<ul class="mt-4 grid gap-3 sm:grid-cols-2">
{#each gameTrackers as game (game.href)}
{@const logo = gameLogos[game.icon ?? game.name]}
{#if logo}
<li class="flex items-center gap-3">
<TrackerIcon name={game.icon ?? game.name} class="size-8" />
<div class="min-w-0">
<p class="text-sm font-medium">{game.name}</p>
<a href={logo.source} target="_blank" rel="noreferrer" class="text-xs underline"
>{logo.credit}</a
>
</div>
</li>
{/if}
{/each}
</ul>
<h2 class="mt-8 text-xl font-semibold">Regional maps</h2>
<ul class="mt-4 space-y-3">
{#each data.maps as map (map.name)}<li>
<a href={map.source} target="_blank" rel="noreferrer" class="font-medium underline"
>{map.name}</a
>
<p class="text-xs text-muted-foreground">{map.credit}</p>
</li>{/each}
</ul>
<h2 class="mt-8 text-xl font-semibold">Covers, sprites and other artwork</h2>
<p class="mt-2 text-sm text-muted-foreground">
{data.total.toLocaleString()} downloaded image records. Box art is archived by Libretro and Serebii;
Pokémon sprites and official artwork are archived by PokéAPI. COD covers and achievement icons come
from Steam.
</p>
<ul class="mt-4 space-y-3">
{#each data.sources as item (item.source)}<li>
<a href={item.source} target="_blank" rel="noreferrer" class="text-sm underline"
>{item.credit}</a
><span class="ml-2 text-xs text-muted-foreground">{item.count} images</span>
</li>{/each}
</ul>
</div>

View File

@@ -380,7 +380,7 @@ test('shared dialogs have focus, Escape dismissal and mobile layouts without ove
).toBe(12345); ).toBe(12345);
await page.setViewportSize({ width: 390, height: 844 }); await page.setViewportSize({ width: 390, height: 844 });
for (const path of [ for (const path of [
'/projects/game-tracker', '/trackers/games',
'/projects/game-tracker/pokemon?game=platinum', '/projects/game-tracker/pokemon?game=platinum',
'/projects/game-tracker/osu', '/projects/game-tracker/osu',
'/projects/game-tracker/call-of-duty/black-ops-3' '/projects/game-tracker/call-of-duty/black-ops-3'
@@ -410,7 +410,7 @@ test('illustrated library uses two Pokémon columns, follows selected games, and
).status ).status
).toBe(200); ).toBe(200);
await page.setViewportSize({ width: 1440, height: 900 }); await page.setViewportSize({ width: 1440, height: 900 });
await page.goto('/projects/game-tracker'); await page.goto('/trackers/games');
await expect(page.locator('.collection-grid .game-row')).toHaveCount(5); await expect(page.locator('.collection-grid .game-row')).toHaveCount(5);
await expect(page.locator('.collection-grid .game-name')).toHaveText([ await expect(page.locator('.collection-grid .game-name')).toHaveText([
'Red', 'Red',
@@ -471,7 +471,7 @@ test('illustrated library uses two Pokémon columns, follows selected games, and
[1280, 720] [1280, 720]
]) { ]) {
await page.setViewportSize({ width, height }); await page.setViewportSize({ width, height });
await page.goto('/projects/game-tracker'); await page.goto('/trackers/games');
await expect(page.locator('.collection-grid .game-row')).toHaveCount(games.length); await expect(page.locator('.collection-grid .game-row')).toHaveCount(games.length);
const size = await page.evaluate(() => ({ const size = await page.evaluate(() => ({
height: document.documentElement.scrollHeight, height: document.documentElement.scrollHeight,
@@ -781,7 +781,7 @@ test('visible progress reacts to sightings, catches and completed stories withou
).status ).status
).toBe(401); ).toBe(401);
await anonymous.dispose(); await anonymous.dispose();
await page.goto('/projects/game-tracker'); await page.goto('/trackers/games');
await expect( await expect(
page.getByRole('progressbar', { name: 'National collection', exact: true }) page.getByRole('progressbar', { name: 'National collection', exact: true })
).toHaveAttribute('aria-valuenow', first!); ).toHaveAttribute('aria-valuenow', first!);
@@ -991,7 +991,7 @@ test('BO2 armoury fits every gun, enforces caps and advances all rank progress w
await expect(card.getByRole('button', { name: 'Level up M27' })).toBeDisabled(); await expect(card.getByRole('button', { name: 'Level up M27' })).toBeDisabled();
await expect(page.getByRole('img', { name: 'Prestige 3 emblem' })).toBeVisible(); await expect(page.getByRole('img', { name: 'Prestige 3 emblem' })).toBeVisible();
const progress = await overall.getAttribute('aria-valuenow'); const progress = await overall.getAttribute('aria-valuenow');
await page.goto('/projects/game-tracker'); await page.goto('/trackers/games');
await expect( await expect(
page.getByRole('progressbar', { name: 'Black Ops II progress', exact: true }) page.getByRole('progressbar', { name: 'Black Ops II progress', exact: true })
).toHaveAttribute('aria-valuenow', progress!); ).toHaveAttribute('aria-valuenow', progress!);