Files
website/src/lib/projects.ts
T

549 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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));
}