Files
website/src/lib/projects.ts
Stan Runge 76bfb3dcec
All checks were successful
Deploy production / deploy (push) Successful in 1m10s
stats: /stats overview, economy, energy and Gouda pages; CBS series map; nav, homepage, /start and sitemap links
- CBS_SERIES: CPI, household tariffs (2007-now), energy balance, renewables, heat pumps,
  solar, cars, population (monthly, since 1942, components), house prices, housing stock,
  municipal levies for Gouda and its neighbours
- shared StatChart/Kpi/chart helpers with per-chart source attribution
- drop Eurostat nrg_pc_20x_h (404; CBS covers that history)
2026-10-25 20:50:47 +00:00

424 lines
14 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.

// 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: '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: 'world opinions (warning: AI slop)',
subtitle: 'ideologies, values and biases across countries',
href: '/projects/world-opinions',
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: open data on the economy, energy and Gouda (src/routes/stats).
export const statsPages = [
{
name: 'economy',
subtitle: 'rates, inflation, bond spreads, GDP and debt',
href: '/stats/economy',
external: false
},
{
name: 'energy',
subtitle: 'tariffs, oil and gas, renewables, heat pumps and EVs',
href: '/stats/energy',
external: false
},
{
name: 'gouda',
subtitle: 'population, housing and neighbourhoods in Gouda',
href: '/stats/gouda',
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
}
];
// 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: 'reading',
subtitle: 'books, scientific papers and articles I am reading, have read and want to read',
href: '/trackers/books',
external: false,
private: false
},
{
name: 'spotting',
subtitle: "cars, trains, planes & wildlife I've spotted",
href: '/trackers/spotting',
external: false,
children: spottingTrackers,
private: false
},
{
name: 'music',
subtitle: 'everything I listen to: top artists, tracks, albums and streaks',
href: '/trackers/music',
external: false,
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: 'health',
subtitle: 'body, workouts, sleep, meds and vaccinations',
href: '/trackers/health',
external: false,
private: true
},
{
name: 'groceries',
subtitle: 'supermarket products, favorites and prices per store',
href: '/trackers/groceries',
external: false,
private: true
},
{
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: 'energy',
subtitle: 'Dutch electricity mix, utilization and gas storage',
href: '/projects/energy',
external: false,
private: false
},
{
name: 'games',
subtitle: 'every pokemon in every game, and call of duty camo mastery',
href: '/trackers/games',
external: false,
children: gameTrackers,
private: false
},
{
name: 'skins',
subtitle: 'Counter-Strike skin market prices across time',
href: '/projects/cs-prices',
external: false,
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`. */
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));
}