trackers: group cars, trains, planes and wildlife under one spotting tracker with /trackers/spotting landing page and category switcher
All checks were successful
Deploy production / deploy (push) Successful in 1m9s

This commit is contained in:
2026-10-25 12:08:38 +00:00
parent 5226ed7fec
commit d8490ce94f
14 changed files with 295 additions and 31 deletions

View File

@@ -0,0 +1,74 @@
<script lang="ts">
// Small switcher shown above the cars/trains/planes/wildlife pages (via their +layout),
// to hop between the spotting pokedexes or back to /trackers/spotting.
import { resolve } from '$app/paths';
import { page } from '$app/state';
import Binoculars from 'lucide-svelte/icons/binoculars';
import TrackerIcon from '#lib/components/TrackerIcon.svelte';
import { spottingTrackers } from '#lib/projects.js';
const active = $derived(
spottingTrackers.find(
(t) => page.url.pathname === t.href || page.url.pathname.startsWith(`${t.href}/`)
)?.name
);
</script>
<nav
aria-label="Spotting trackers"
class="relative mx-auto -mb-6 max-w-6xl px-4 pt-6 sm:-mb-8 sm:px-6 sm:pt-8"
>
<div class="flex flex-wrap items-center gap-1.5">
<a
href={resolve('/trackers/spotting')}
class="pill flex items-center gap-1.5 px-2.5 py-1.5 text-sm"
><Binoculars size={14} aria-hidden="true" class="text-[var(--site-muted)]" />spotting</a
>
<span class="mx-1 h-4 w-px bg-border" aria-hidden="true"></span>
{#each spottingTrackers as t (t.name)}
<a
href={resolve(t.href as '/')}
aria-current={active === t.name ? 'page' : undefined}
class="pill flex items-center gap-1.5 py-1 pr-2.5 pl-1 text-sm"
><TrackerIcon name={t.name} class="pill-icon size-6" />{t.name}</a
>
{/each}
</div>
</nav>
<style>
.pill {
border: 1px solid color-mix(in oklab, var(--foreground) 9%, transparent);
border-radius: 999px;
background: color-mix(in oklab, var(--foreground) 2%, transparent);
color: var(--foreground);
outline: none;
transition:
transform 160ms cubic-bezier(0.2, 0.8, 0.2, 1),
background-color 160ms ease,
border-color 160ms ease;
}
.pill:hover {
background: color-mix(in oklab, var(--foreground) 6%, transparent);
border-color: color-mix(in oklab, var(--foreground) 22%, transparent);
transform: translateY(-1px);
}
.pill:focus-visible {
outline: 2px solid var(--foreground);
outline-offset: 2px;
}
.pill[aria-current='page'] {
background: color-mix(in oklab, var(--foreground) 10%, transparent);
border-color: color-mix(in oklab, var(--foreground) 35%, transparent);
font-weight: 500;
}
.pill :global(.pill-icon) {
border-radius: 999px;
}
@media (prefers-reduced-motion: reduce) {
.pill,
.pill:hover {
transform: none;
}
}
</style>

View File

@@ -7,13 +7,13 @@ describe('trackers', () => {
visibleTrackers(true)
.map((t) => t.name)
.slice(0, 4)
).toEqual(['tasks', 'money', 'books', 'cars']);
).toEqual(['tasks', 'money', 'books', 'spotting']);
});
it('hides private trackers when logged out', () => {
const names = visibleTrackers(false).map((t) => t.name);
expect(names).not.toContain('tasks');
expect(names).not.toContain('health');
expect(names.slice(0, 3)).toEqual(['money', 'books', 'cars']);
expect(names.slice(0, 3)).toEqual(['money', 'books', 'spotting']);
});
it('uses short single-word names', () => {
for (const t of visibleTrackers(true)) expect(t.name).toMatch(/^[a-z]+$/);

View File

@@ -59,9 +59,9 @@ const allTrackers = [
private: false
},
{
name: 'cars',
subtitle: 'a car pokedex: every brand and model, with Dutch and US data',
href: '/trackers/cars',
name: 'spotting',
subtitle: "cars, trains, planes & wildlife I've spotted",
href: '/trackers/spotting',
external: false,
private: false
},
@@ -114,7 +114,6 @@ const allTrackers = [
external: false,
private: false
},
{
name: 'skins',
subtitle: 'Counter-Strike skin market prices across time',
@@ -135,29 +134,6 @@ const allTrackers = [
href: '/lists',
external: false,
private: false
},
{
name: 'planes',
subtitle: 'a plane pokedex: airliners, helicopters, bizjets, light aircraft and warbirds',
href: '/trackers/planes',
external: false,
private: 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,
private: false
},
{
name: 'wildlife',
subtitle:
'a wild animal pokedex: everything you can spot in the Netherlands, plus famous animals worldwide',
href: '/trackers/wildlife',
external: false,
private: false
}
] as const;
@@ -168,6 +144,36 @@ const strip = ({ name, subtitle, href, external }: Tracker): Tracker => ({
href,
external
});
// 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
}
];
/** Public trackers (sitemap, logged-out navigation). */
export const trackers: readonly Tracker[] = allTrackers.filter((t) => !t.private).map(strip);
/** Owner-only trackers. */

View File

@@ -44,6 +44,11 @@ const publicPages: Record<string, { title: string; description: string }> = {
title: 'Books',
description: 'Stan Runge’s library: books being read, finished and on the to-read pile.'
},
'/trackers/spotting': {
title: 'Spotting',
description:
'Stan Runge’s spotting pokedexes: cars, trains, planes and wildlife, with specs, photos and how to recognise them.'
},
'/trackers/cars': {
title: 'Car pokedex',
description:

View File

@@ -19,6 +19,7 @@ export function getTitle(path: string, environment: SiteEnvironment = null): str
'/projects': 'Projects',
'/trackers': 'Trackers',
'/trackers/books': 'Books',
'/trackers/spotting': 'Spotting',
'/trackers/cars': 'Cars',
'/trackers/planes': 'Planes',
'/trackers/trains': 'Trains',

View File

@@ -1,10 +1,10 @@
import { describe, expect, it } from 'vitest';
import { trackerIcons } from './tracker-icons';
import { visibleTrackers } from './projects';
import { spottingTrackers, visibleTrackers } from './projects';
describe('tracker icons', () => {
it('gives every tracker its own colour', () => {
const names = visibleTrackers(true).map((t) => t.name);
const names = [...visibleTrackers(true), ...spottingTrackers].map((t) => t.name);
for (const name of names) expect(trackerIcons[name], name).toBeDefined();
const colours = names.map((n) => trackerIcons[n].to);
expect(new Set(colours).size).toBe(colours.length);

View File

@@ -144,6 +144,20 @@ export const trackerIcons: Record<string, TrackerIcon> = {
'M2 21c0-3 1.85-5.36 5.08-6C9.5 14.52 12 13 13 12'
]
},
spotting: {
from: '#fbbf24',
to: '#e11d48',
ink: WHITE,
// binoculars
paths: [
'M10 10h4',
'M19 7V4a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v3',
'M20 21a2 2 0 0 0 2-2v-3.851c0-1.39-2-2.962-2-4.829V8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v11a2 2 0 0 0 2 2z',
'M22 16H2',
'M4 21a2 2 0 0 1-2-2v-3.851c0-1.39 2-2.962 2-4.829V8a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v11a2 2 0 0 1-2 2z',
'M9 7V4a1 1 0 0 0-1-1H7a1 1 0 0 0-1 1v3'
]
},
lists: {
from: '#94a3b8',
to: '#475569',