549 lines
18 KiB
TypeScript
549 lines
18 KiB
TypeScript
import { MATH_PRIVATE } from './math';
|
||
// Real projects (tools and experiments). Personal data trackers live in `trackers`.
|
||
export const projects = [
|
||
{
|
||
name: 'vash esports',
|
||
subtitle: 'the best way to play osu!',
|
||
href: 'https://esports.vash.software',
|
||
external: true
|
||
},
|
||
{
|
||
name: 'worldwide todo list',
|
||
subtitle:
|
||
'real problems, planet to street, as a public todo list anyone can suggest changes to',
|
||
href: '/projects/worldwide-todo',
|
||
external: false
|
||
},
|
||
{
|
||
name: 'osu! map finder',
|
||
subtitle: 'find osu! maps for Spotify songs/albums/playlists',
|
||
href: '/projects/spotify-beatmap-finder',
|
||
external: false
|
||
},
|
||
{
|
||
name: 'derby lens (warning: AI slop)',
|
||
subtitle: 'estimate odds for horse betting',
|
||
href: '/projects/derby',
|
||
external: false
|
||
},
|
||
{
|
||
name: 'language learner (warning: AI slop)',
|
||
subtitle: 'Dutch, Korean and English, from zero to fluent',
|
||
href: '/projects/language-learner',
|
||
external: false
|
||
}
|
||
] as const;
|
||
|
||
// The public /stats section (src/routes/stats): a place × theme matrix; these are the shortcuts.
|
||
export const statsPages = [
|
||
{
|
||
name: 'economy',
|
||
icon: 'stats-economy',
|
||
subtitle: 'rates, inflation, bond spreads, GDP and debt',
|
||
href: '/stats/core/economy',
|
||
external: false
|
||
},
|
||
{
|
||
name: 'jobs & cost of living',
|
||
icon: 'stats-jobs',
|
||
subtitle: 'unemployment, vacancies, graduate outcomes, wages, rents and price levels',
|
||
href: '/stats/core/jobs',
|
||
external: false
|
||
},
|
||
{
|
||
name: 'energy',
|
||
icon: 'stats-energy',
|
||
subtitle: 'live grid mix and gas storage, tariffs, oil and gas, renewables, heat pumps and EVs',
|
||
href: '/stats/nld/energy',
|
||
external: false
|
||
},
|
||
{
|
||
name: 'gouda',
|
||
icon: 'stats-gouda',
|
||
subtitle: 'population, housing and neighbourhoods in Gouda',
|
||
href: '/stats/gouda/overview',
|
||
external: false
|
||
},
|
||
{
|
||
name: 'countries',
|
||
icon: 'stats-countries',
|
||
subtitle: 'the Netherlands, Curaçao, the US, Korea, China and more side by side',
|
||
href: '/stats/core/overview',
|
||
external: false
|
||
},
|
||
{
|
||
name: 'elections',
|
||
icon: 'stats-elections',
|
||
subtitle: 'Dutch elections since 1918, Gouda per polling station, US presidential',
|
||
href: '/stats/nld/politics',
|
||
external: false
|
||
},
|
||
{
|
||
name: 'history',
|
||
icon: 'stats-history',
|
||
subtitle: 'the long run, a sourced timeline, politics and culture of five places',
|
||
href: '/stats/world/history',
|
||
external: false
|
||
},
|
||
{
|
||
name: 'opinions',
|
||
icon: 'stats-opinions',
|
||
subtitle: 'polls, Eurobarometer, happiness, party positions and world opinions',
|
||
href: '/stats/core/politics',
|
||
external: false
|
||
}
|
||
] as const;
|
||
|
||
type Tracker = {
|
||
name: string;
|
||
subtitle: string;
|
||
href: string;
|
||
external: boolean;
|
||
/** Sub-trackers linked directly from this item (header flyout, tile chips). */
|
||
children?: readonly Tracker[];
|
||
/** Icon key in #lib/tracker-icons when it differs from `name` (games). */
|
||
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;
|
||
};
|
||
|
||
// The spotting trackers: pokedexes of things I can spot in the wild. Grouped under one
|
||
// "spotting" item in the header, home page and /trackers; each keeps its own URL.
|
||
export const spottingTrackers: readonly Tracker[] = [
|
||
{
|
||
name: 'cars',
|
||
subtitle: 'a car pokedex: every brand and model, with Dutch and US data',
|
||
href: '/trackers/cars',
|
||
external: false
|
||
},
|
||
{
|
||
name: 'trains',
|
||
subtitle:
|
||
'a train pokedex: steam, diesel and electric locos, high-speed trains, multiple units, metros and trams',
|
||
href: '/trackers/trains',
|
||
external: false
|
||
},
|
||
{
|
||
name: 'planes',
|
||
subtitle: 'a plane pokedex: airliners, helicopters, bizjets, light aircraft and warbirds',
|
||
href: '/trackers/planes',
|
||
external: false
|
||
},
|
||
{
|
||
name: 'wildlife',
|
||
subtitle:
|
||
'a wild animal pokedex: everything you can spot in the Netherlands, plus famous animals worldwide',
|
||
href: '/trackers/wildlife',
|
||
external: false
|
||
}
|
||
];
|
||
|
||
// Media: everything we read, watch and listen to, grouped under one "media" item like spotting.
|
||
// Reading, movies and music keep their own trackers and URLs; shows and anime are views of the
|
||
// movies tracker (/trackers/shows redirects to /trackers/movies?type=show); webtoons, podcasts
|
||
// and videos share media.items (#lib/media/kinds). /trackers/media is the hub.
|
||
export const mediaTrackers: readonly Tracker[] = [
|
||
{
|
||
name: 'reading',
|
||
subtitle: 'books, scientific papers and articles I am reading, have read and want to read',
|
||
href: '/trackers/books',
|
||
external: false
|
||
},
|
||
{
|
||
name: 'movies',
|
||
subtitle: 'movies we watch: Netflix, Plex and cinema, ratings and stats',
|
||
href: '/trackers/movies',
|
||
external: false
|
||
},
|
||
{
|
||
name: 'shows',
|
||
subtitle: 'TV shows and anime we watch, episode by episode',
|
||
href: '/trackers/shows',
|
||
external: false
|
||
},
|
||
{
|
||
name: 'webtoons',
|
||
subtitle: 'webtoons and manhwa we read, chapter by chapter',
|
||
href: '/trackers/webtoons',
|
||
external: false
|
||
},
|
||
{
|
||
name: 'music',
|
||
subtitle: 'everything I listen to: top artists, tracks, albums and streaks',
|
||
href: '/trackers/music',
|
||
external: false
|
||
},
|
||
{
|
||
name: 'podcasts',
|
||
subtitle: 'podcasts we follow and where we are in them',
|
||
href: '/trackers/podcasts',
|
||
external: false
|
||
},
|
||
{
|
||
name: 'videos',
|
||
subtitle: 'YouTube channels, videos and playlists worth watching',
|
||
href: '/trackers/videos',
|
||
external: false
|
||
}
|
||
];
|
||
|
||
// Games I play (plus speedcubing), grouped under "games" like spotting: every game has its
|
||
// 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).
|
||
const game = (slug: string, name: string, subtitle: string, featured = false): Tracker => ({
|
||
name,
|
||
subtitle,
|
||
href: `/trackers/games/${slug}`,
|
||
external: false,
|
||
icon: `game-${slug}`,
|
||
...(featured ? { featured } : {})
|
||
});
|
||
export const gameTrackers: readonly Tracker[] = [
|
||
game('osu', 'osu!', 'rhythm game, my main focus right now', true),
|
||
game('cs2', 'cs2', 'Counter-Strike 2 (CS:GO)', true),
|
||
game('pubg', 'pubg', 'PUBG: Battlegrounds', true),
|
||
game('r6', 'r6 siege', 'Tom Clancy’s Rainbow Six Siege', true),
|
||
game('tarkov', 'tarkov', 'Escape from Tarkov', true),
|
||
game('black-ops-2', 'black ops 2', 'Call of Duty: Black Ops II (2012)', true),
|
||
game('valorant', 'valorant', 'Riot’s tactical shooter', false),
|
||
game('lol', 'league', 'League of Legends', false),
|
||
game('overwatch', 'overwatch 2', 'Blizzard’s hero shooter', false),
|
||
game('minecraft', 'minecraft', 'blocks, mining and building', false),
|
||
game('factorio', 'factorio', 'factory building and automation', false),
|
||
game('ksp', 'ksp', 'Kerbal Space Program', false),
|
||
game('smash', 'smash bros', 'Super Smash Bros.', false),
|
||
game('cubing', 'speedcubing', 'Rubik’s cube solving, not a video game', false),
|
||
game('sot', 'sea of thieves', 'pirate sandbox', false),
|
||
// Call of Duty, one entry per title (the Black Ops 2 one is featured above).
|
||
game('black-ops', 'black ops', 'Call of Duty: Black Ops (2010)'),
|
||
game('ghosts', 'ghosts', 'Call of Duty: Ghosts (2013)'),
|
||
game('advanced-warfare', 'advanced warfare', 'Call of Duty: Advanced Warfare (2014)'),
|
||
game('black-ops-3', 'black ops 3', 'Call of Duty: Black Ops III (2015)'),
|
||
game('infinite-warfare', 'infinite warfare', 'Call of Duty: Infinite Warfare (2016)'),
|
||
game('wwii', 'wwii', 'Call of Duty: WWII (2017)'),
|
||
// Pokémon, one entry per generation; remakes and spin-offs get their own.
|
||
game('pokemon-red-blue-yellow', 'pokémon red, blue & yellow', 'generation 1, Game Boy'),
|
||
game(
|
||
'pokemon-gold-silver-crystal',
|
||
'pokémon gold, silver & crystal',
|
||
'generation 2, Game Boy Color'
|
||
),
|
||
game(
|
||
'pokemon-ruby-sapphire-emerald',
|
||
'pokémon ruby, sapphire & emerald',
|
||
'generation 3, Game Boy Advance'
|
||
),
|
||
game('pokemon-colosseum', 'pokémon colosseum', 'generation 3 spin-off, GameCube'),
|
||
game('pokemon-xd', 'pokémon xd: gale of darkness', 'generation 3 spin-off, GameCube'),
|
||
game(
|
||
'pokemon-firered-leafgreen',
|
||
'pokémon firered & leafgreen',
|
||
'generation 1 remake, Game Boy Advance'
|
||
),
|
||
game(
|
||
'pokemon-diamond-pearl-platinum',
|
||
'pokémon diamond, pearl & platinum',
|
||
'generation 4, Nintendo DS'
|
||
),
|
||
game(
|
||
'pokemon-heartgold-soulsilver',
|
||
'pokémon heartgold & soulsilver',
|
||
'generation 2 remake, Nintendo DS'
|
||
),
|
||
game(
|
||
'pokemon-black-white',
|
||
'pokémon black & white',
|
||
'generation 5 with Black 2 & White 2, Nintendo DS'
|
||
),
|
||
game('pokemon-x-y', 'pokémon x & y', 'generation 6, Nintendo 3DS'),
|
||
game(
|
||
'pokemon-omega-ruby-alpha-sapphire',
|
||
'pokémon omega ruby & alpha sapphire',
|
||
'generation 3 remake, Nintendo 3DS'
|
||
),
|
||
game(
|
||
'pokemon-sun-moon',
|
||
'pokémon sun & moon',
|
||
'generation 7 with Ultra Sun & Ultra Moon, Nintendo 3DS'
|
||
),
|
||
game(
|
||
'pokemon-lets-go',
|
||
'pokémon let’s go, pikachu & eevee',
|
||
'generation 1 remake, Nintendo Switch'
|
||
),
|
||
game(
|
||
'pokemon-sword-shield',
|
||
'pokémon sword & shield',
|
||
'generation 8 with The Isle of Armor & The Crown Tundra, Nintendo Switch'
|
||
),
|
||
game(
|
||
'pokemon-brilliant-diamond-shining-pearl',
|
||
'pokémon brilliant diamond & shining pearl',
|
||
'generation 4 remake, Nintendo Switch'
|
||
),
|
||
game('pokemon-legends-arceus', 'pokémon legends: arceus', 'generation 8, Nintendo Switch'),
|
||
game(
|
||
'pokemon-scarlet-violet',
|
||
'pokémon scarlet & violet',
|
||
'generation 9 with The Teal Mask & The Indigo Disk, 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
|
||
// /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
|
||
// in the sitemap; their routes 404 for everyone else. Private trackers, and listed ones that
|
||
// still need a login (`locked`, e.g. money), get a lock icon next to their name everywhere.
|
||
const allTrackers: readonly (Tracker & { private: boolean })[] = [
|
||
{
|
||
name: 'tasks',
|
||
subtitle: 'to-dos, goals and my TickTick history',
|
||
href: '/trackers/tasks',
|
||
external: false,
|
||
private: true
|
||
},
|
||
{
|
||
name: 'money',
|
||
subtitle: 'a manual ledger for accounts, bills and utilities',
|
||
href: '/trackers/money',
|
||
external: false,
|
||
locked: true,
|
||
private: false
|
||
},
|
||
{
|
||
name: 'media',
|
||
subtitle: 'reading, movies, shows, webtoons, music, podcasts and videos',
|
||
href: '/trackers/media',
|
||
external: false,
|
||
children: mediaTrackers,
|
||
private: false
|
||
},
|
||
{
|
||
name: 'spotting',
|
||
subtitle: "cars, trains, planes & wildlife I've spotted",
|
||
href: '/trackers/spotting',
|
||
external: false,
|
||
children: spottingTrackers,
|
||
private: false
|
||
},
|
||
{
|
||
name: 'pc',
|
||
subtitle: 'my PC setup: every part, my devices, the next build and mouse sensitivities',
|
||
href: '/trackers/pc',
|
||
external: false,
|
||
private: false
|
||
},
|
||
{
|
||
name: 'tools',
|
||
subtitle: 'my tools, batteries and consumables: what I have and how many',
|
||
href: '/trackers/tools',
|
||
external: false,
|
||
private: true
|
||
},
|
||
{
|
||
name: 'ai',
|
||
subtitle: 'AI and software news, my bots and how much I use Claude, ChatGPT and Gemini',
|
||
href: '/trackers/ai',
|
||
external: false,
|
||
private: false
|
||
},
|
||
{
|
||
name: 'notes',
|
||
subtitle: 'my Obsidian vault: notes, wiki and daily notes, live-synced both ways',
|
||
href: '/trackers/notes',
|
||
external: false,
|
||
private: true
|
||
},
|
||
{
|
||
name: 'cargill',
|
||
subtitle: 'what the Cargill Bioindustrial plant in Gouda does: process, products and words',
|
||
href: '/trackers/cargill',
|
||
external: false,
|
||
private: true
|
||
},
|
||
{
|
||
name: 'radio',
|
||
subtitle: "Opa's scanner frequencies, banks, nets, ISS passes and my listening log",
|
||
href: '/trackers/radio',
|
||
external: false,
|
||
private: false
|
||
},
|
||
{
|
||
name: 'math',
|
||
subtitle: "working through Evan Chen's Napkin: chapters, exercises and notes",
|
||
href: '/trackers/math',
|
||
external: false,
|
||
private: MATH_PRIVATE
|
||
},
|
||
{
|
||
name: 'health',
|
||
subtitle: 'body, workouts, sleep, meds and vaccinations',
|
||
href: '/trackers/health',
|
||
external: false,
|
||
private: true
|
||
},
|
||
{
|
||
// One price tracker with a tab per thing (#lib/price-trackers): groceries (mine) and CS skins.
|
||
name: 'prices',
|
||
subtitle: 'prices across time: groceries per store and Counter-Strike skins',
|
||
href: '/trackers/prices',
|
||
external: false,
|
||
private: false
|
||
},
|
||
{
|
||
name: 'houses',
|
||
subtitle: 'rentals around Gouda, applications and alerts',
|
||
href: '/trackers/houses',
|
||
external: false,
|
||
private: true
|
||
},
|
||
{
|
||
name: 'holders',
|
||
subtitle: 'who has my address, email, phone and bank details, and a moving checklist',
|
||
href: '/trackers/data-holders',
|
||
external: false,
|
||
private: true
|
||
},
|
||
{
|
||
name: 'games',
|
||
subtitle: 'every pokemon in every game, and call of duty camo mastery',
|
||
href: '/trackers/games',
|
||
external: false,
|
||
children: gameTrackers,
|
||
private: false
|
||
},
|
||
{
|
||
name: 'pets',
|
||
subtitle: 'animaldex: add neighborhood pets to the world map',
|
||
href: '/projects/animaldex',
|
||
external: false,
|
||
private: false
|
||
},
|
||
{
|
||
name: 'lists',
|
||
subtitle: 'books, games, places and other things worth keeping',
|
||
href: '/lists',
|
||
external: false,
|
||
private: false
|
||
}
|
||
];
|
||
|
||
const strip = ({
|
||
name,
|
||
subtitle,
|
||
href,
|
||
external,
|
||
children,
|
||
locked,
|
||
private: hidden
|
||
}: Tracker & { private: boolean }): Tracker => ({
|
||
name,
|
||
subtitle,
|
||
href,
|
||
external,
|
||
...(children ? { children } : {}),
|
||
...(locked || hidden ? { locked: true } : {})
|
||
});
|
||
/** Public trackers (sitemap, logged-out navigation). */
|
||
export const trackers: readonly Tracker[] = allTrackers.filter((t) => !t.private).map(strip);
|
||
/** Owner-only trackers. */
|
||
export const privateTrackers: readonly Tracker[] = allTrackers.filter((t) => t.private).map(strip);
|
||
const ownerTrackers: readonly Tracker[] = allTrackers.map(strip);
|
||
|
||
/** 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`.
|
||
* `hidden` (root layout `hiddenTrackers`, e.g. movies when its owner switch is private) drops
|
||
* those trackers for everyone else and gives them a lock for the owner. */
|
||
export function visibleTrackers(
|
||
isOwner: boolean,
|
||
inactiveGames: readonly string[] = [],
|
||
hidden: readonly string[] = []
|
||
): readonly Tracker[] {
|
||
let list = isOwner ? ownerTrackers : trackers;
|
||
if (hidden.length) {
|
||
// Hidden trackers can sit at the top level or inside a group (movies and shows in media).
|
||
const hide = (items: readonly Tracker[]): readonly Tracker[] =>
|
||
isOwner
|
||
? items.map((t) => (hidden.includes(t.name) ? { ...t, locked: true } : t))
|
||
: items.filter((t) => !hidden.includes(t.name));
|
||
list = hide(list).map((t) =>
|
||
t.children && t.children !== gameTrackers ? { ...t, children: hide(t.children) } : t
|
||
);
|
||
}
|
||
if (!inactiveGames.length) return list;
|
||
const games = sortedGames(inactiveGames);
|
||
return list.map((t) => (t.children === gameTrackers ? { ...t, children: games } : t));
|
||
}
|