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

@@ -18,6 +18,7 @@
children?: readonly Item[];
icon?: string;
featured?: boolean;
inactive?: boolean;
locked?: boolean;
};
let {
@@ -33,18 +34,28 @@
} = $props();
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 hovered = $state<string | null>(null);
const open = (group: Item) => !!expanded[group.name] || hovered === group.name;
const visibleChildren = (group: Item) => {
const all = group.children ?? [];
return expanded[group.name]
? all
: all.some((c) => c.featured)
? all.filter((c) => c.featured)
: all;
return open(group) ? all : all.some((c) => c.featured) ? all.filter((c) => c.featured) : all;
};
const hiddenCount = (group: Item) =>
(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>
{#snippet body(project: Item)}
@@ -81,7 +92,14 @@
<ul class="grid gap-3 sm:grid-cols-2" class:wide>
{#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.Content class="h-full p-0">
{#if project.children}
@@ -95,20 +113,30 @@
{@render body(project)}
</a>
<ul class="relative z-10 mt-auto flex flex-wrap gap-1.5 pl-13">
{#each visibleChildren(project) as child (child.name)}
<li>
<a
href={resolve(child.href as '/')}
class="chip flex items-center gap-1.5 py-1 pr-2.5 pl-1 text-xs font-medium"
title={child.subtitle}
><TrackerIcon
name={child.icon ?? child.name}
class="chip-icon size-5"
/>{child.name}</a
>
</li>
{#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>
<a
href={resolve(child.href as '/')}
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}
><TrackerIcon
name={child.icon ?? child.name}
class="chip-icon size-5"
/>{child.name}</a
>
</li>
{/each}
{/each}
{#if hiddenCount(project) > 0 || expanded[project.name]}
{#if hiddenCount(project) > 0 || (expanded[project.name] && hovered !== project.name)}
<li>
<button
type="button"
@@ -206,6 +234,33 @@
border-color: color-mix(in oklab, var(--foreground) 35%, transparent);
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 {
outline: 2px solid var(--foreground);
outline-offset: 2px;

View File

@@ -38,6 +38,7 @@
user: { name: string } | null;
isOwner: boolean;
version: string;
inactiveGames?: string[];
};
let { data, children }: { data: ChromeData; children: Snippet } = $props();
const showFooter = $derived(!hidesFooter(page.url.pathname));
@@ -59,10 +60,12 @@
href: string;
external: boolean;
children?: readonly MenuItem[];
icon?: string;
inactive?: boolean;
};
const menus: Record<string, { label: string; items: readonly MenuItem[] }> = $derived({
'/projects': { label: 'projects', items: projects },
'/trackers': { label: 'trackers', items: visibleTrackers(data.isOwner) }
'/trackers': { label: 'trackers', items: visibleTrackers(data.isOwner, data.inactiveGames) }
});
const socialLinks = [
{ icon: SiMail, label: 'email', href: 'mailto:stan@stanrunge.dev' },
@@ -197,7 +200,7 @@
if (flyout === name) return;
const columns = flyoutColumns(count);
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 panel = el.closest('[data-menu-panel]')?.getBoundingClientRect() ?? rect;
const leftColumn = rect.left + rect.width / 2 < panel.left + panel.width / 2;
@@ -207,6 +210,15 @@
flyoutSide = leftColumn ? (roomLeft ? 'left' : 'right') : roomRight ? 'right' : 'left';
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) {
if (event.key !== 'Escape' || !flyout) return;
event.stopPropagation();
@@ -385,18 +397,28 @@
]}
class:from-left={flyoutSide === 'left'}
>
{#each project.children as child (child.name)}
<a
href={resolve(child.href as '/')}
class="tile flex min-w-0 items-center gap-2.5 p-1.5 text-sm"
title={`${child.name}: ${child.subtitle}`}
aria-current={current(child.href) ? 'page' : undefined}
onkeydown={closeFlyoutOnEscape}
><TrackerIcon
name={child.icon ?? child.name}
class="tile-icon size-8"
/><span class="truncate font-medium">{child.name}</span></a
>
{#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
href={resolve(child.href as '/')}
class={[
'tile flex min-w-0 items-center gap-2.5 p-1.5 text-sm',
child.inactive && 'inactive'
]}
title={`${child.name}: ${child.subtitle}`}
aria-current={current(child.href) ? 'page' : undefined}
onkeydown={closeFlyoutOnEscape}
><TrackerIcon
name={child.icon ?? child.name}
class="tile-icon size-8"
/><span class="truncate font-medium">{child.name}</span></a
>
{/each}
{/each}
</div>
</div>
@@ -551,16 +573,26 @@
<div class="col-span-2 mt-1">
<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">
{#each group.children ?? [] as child (child.name)}
<a
href={resolve(child.href as '/')}
class="tile flex min-w-0 items-center gap-2 p-1.5 text-sm"
onclick={() => (menuOpen = false)}
><TrackerIcon
name={child.icon ?? child.name}
class="tile-icon size-6"
/><span class="truncate">{child.name}</span></a
>
{#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
href={resolve(child.href as '/')}
class={[
'tile flex min-w-0 items-center gap-2 p-1.5 text-sm',
child.inactive && 'inactive'
]}
onclick={() => (menuOpen = false)}
><TrackerIcon
name={child.icon ?? child.name}
class="tile-icon size-6"
/><span class="truncate">{child.name}</span></a
>
{/each}
{/each}
</div>
</div>
@@ -617,7 +649,7 @@
{#if showFooter}
<footer
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]'}
>
<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);
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 {
animation: flyout-in 140ms cubic-bezier(0.2, 0.8, 0.2, 1);
--flyout-from: -4px;

View File

@@ -12,7 +12,7 @@ export type GameLogo = {
bleed?: boolean;
/** Wide wordmark (Pokémon logos): use more of the tile so it stays readable. */
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;
source: string;
};

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { visibleTrackers } from './projects';
import { activeGames, pokemonEntryFor, sortedGames, visibleTrackers } from './projects';
describe('trackers', () => {
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 === '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;
/** Shown on group tiles before the "more" chip (main and actively played games). */
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. */
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.
// `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).
// 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
// 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).
@@ -185,6 +190,66 @@ export const gameTrackers: readonly Tracker[] = [
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
// /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
@@ -273,7 +338,7 @@ const allTrackers: readonly (Tracker & { private: boolean })[] = [
{
name: 'games',
subtitle: 'every pokemon in every game, and call of duty camo mastery',
href: '/projects/game-tracker',
href: '/trackers/games',
external: false,
children: gameTrackers,
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);
const ownerTrackers: readonly Tracker[] = allTrackers.map(strip);
export function visibleTrackers(isOwner: boolean): readonly Tracker[] {
return isOwner ? ownerTrackers : trackers;
/** Trackers for the header, home, /trackers, /start and error pages; with `inactiveGames` (root
* 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.'
},
'/lists': { title: 'Lists', description: 'Lists collected and maintained by Stan Runge.' },
'/projects/game-tracker': {
'/trackers/games': {
title: 'Game Tracker',
description:
'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 { check, integer, text } from 'drizzle-orm/pg-core';
import { boolean, check, integer, text } from 'drizzle-orm/pg-core';
import { gamesSchema } from './schemas';
import { id, timestamps } from './helpers';
// 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
// (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
// and are linked through `tracker_href`.
export const GAME_STATUSES = ['main', 'active', 'occasional', 'unknown'] as const;
@@ -36,6 +37,9 @@ export const gameLibrary = gamesSchema.table(
.default(sql`'{}'::text[]`),
notes: text('notes'),
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
},
(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/cs-prices': 'CS Prices',
'/projects/energy': 'Energy',
'/projects/game-tracker': 'Game Tracker',
'/trackers/games': 'Game Tracker',
'/projects/language-learner': 'Language Learner',
'/projects/spotify-beatmap-finder': 'osu! map finder',
'/projects/progress': 'Progress',