beeeeg update
Some checks failed
Test and deploy staging / staging (push) Has been cancelled

This commit is contained in:
2026-09-23 04:15:10 +00:00
parent 487eff5bb2
commit d0c7b6548b
135 changed files with 9531 additions and 1777 deletions

2
src/lib/auth-client.ts Normal file
View File

@@ -0,0 +1,2 @@
import { createAuthClient } from 'better-auth/svelte';
export const authClient = createAuthClient();

View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { safeReturnTo } from './auth-redirect';
describe('login return destinations', () => {
it.each([
null,
'',
'https://evil.example',
'//evil.example',
'/\\evil.example',
'/\nevil.example',
'/api/auth/callback/osu',
'/logout',
'/login?returnTo=/blog'
])('rejects %j', (value) => {
expect(safeReturnTo(value)).toBe('/account');
});
it('keeps a local project destination and its query', () => {
expect(safeReturnTo('/projects/catch-em-all?game=red#progress')).toBe(
'/projects/catch-em-all?game=red#progress'
);
});
it('normalizes paths before rejecting auth endpoints', () => {
expect(safeReturnTo('/projects/../logout')).toBe('/account');
});
});

13
src/lib/auth-redirect.ts Normal file
View File

@@ -0,0 +1,13 @@
export function safeReturnTo(value: string | null): string {
if (!value || !value.startsWith('/') || value.startsWith('//') || /[\\\s]/.test(value)) {
return '/account';
}
const url = new URL(value, 'https://local.invalid');
if (
url.origin !== 'https://local.invalid' ||
/^\/(api\/auth|login|register|logout)(\/|$)/.test(url.pathname)
) {
return '/account';
}
return url.pathname + url.search + url.hash;
}

View File

@@ -1,12 +1,13 @@
// how a pokemon can be obtained in a game. no pokemon_game_entry row at all
// means the pokemon doesn't exist in that game (e.g. a gen 2 mon in gen 1).
// How a Pokémon can be obtained in a game. Missing rows mean no source record;
// they do not prove absence. Unknown rows establish presence, not availability.
// Shared between the db schema and the client, which receives availability as
// an index into this array to keep the page payload small.
export const availabilityValues = [
'catchable',
'trade_only',
'transfer_only',
'event_only'
'event_only',
'unknown'
] as const;
export type Availability = (typeof availabilityValues)[number];

View File

@@ -0,0 +1,149 @@
<script lang="ts">
import { authClient } from '$lib/auth-client';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
let {
mode,
providers,
returnTo,
oauthError
}: {
mode: 'login' | 'register';
providers: { google: boolean; osu: boolean };
returnTo: string;
oauthError: boolean;
} = $props();
let name = $state('');
let email = $state('');
let password = $state('');
let busy = $state(false);
let ready = $state(false);
onMount(() => {
ready = true;
});
let message = $state('');
async function submit(event: SubmitEvent) {
event.preventDefault();
const destination = `${resolve('/')}${returnTo.slice(1)}`;
busy = true;
message = '';
try {
const result =
mode === 'register'
? await authClient.signUp.email({ name: name.trim(), email, password })
: await authClient.signIn.email({ email, password });
if (result.error) {
message = result.error.message || 'Unable to sign in. Please try again.';
return;
}
// Destination is a validated local path, prefixed by resolve('/') above.
// eslint-disable-next-line svelte/no-navigation-without-resolve
await goto(destination, { invalidateAll: true });
} catch {
message = 'Unable to connect. Please try again.';
} finally {
busy = false;
}
}
async function social(provider: 'google' | 'osu') {
busy = true;
message = '';
try {
const result = await authClient.signIn.social({
provider,
callbackURL: returnTo,
errorCallbackURL: `/login?returnTo=${encodeURIComponent(returnTo)}`
});
if (result.error) {
message = result.error.message || 'Unable to connect to this provider.';
busy = false;
}
} catch {
message = 'Unable to connect. Please try again.';
busy = false;
}
}
</script>
<section class="mx-auto my-12 max-w-md px-4 sm:my-20">
<h1 class="mb-8 text-3xl font-semibold tracking-tight">
{mode === 'register' ? 'create account' : 'sign in'}
</h1>
{#if message || oauthError}
<p role="alert" class="mb-4 rounded bg-red-50 p-3 text-red-800">
{message ||
'Provider sign-in failed or was cancelled. Try again, or use your existing login and link the provider from your account.'}
</p>
{/if}
<form method="POST" onsubmit={submit} class="flex flex-col gap-4">
{#if mode === 'register'}
<label class="flex flex-col gap-1"
>Name<input
class="rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2"
disabled={!ready || busy}
name="name"
autocomplete="name"
required
maxlength="100"
bind:value={name}
/></label
>
{/if}
<label class="flex flex-col gap-1"
>Email<input
class="rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2"
disabled={!ready || busy}
name="email"
type="email"
autocomplete="email"
required
bind:value={email}
/></label
>
<label class="flex flex-col gap-1"
>Password<input
class="rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2"
disabled={!ready || busy}
name="password"
type="password"
autocomplete={mode === 'register' ? 'new-password' : 'current-password'}
required
minlength={mode === 'register' ? 12 : undefined}
maxlength="128"
bind:value={password}
/></label
>
{#if mode === 'register'}<p class="text-sm text-gray-600">Use at least 12 characters.</p>{/if}
<button
disabled={!ready || busy}
class="rounded bg-[var(--site-text)] px-4 py-2 font-semibold text-[var(--site-bg)] disabled:opacity-50"
>{busy ? 'please wait…' : mode === 'register' ? 'create account' : 'sign in'}</button
>
</form>
{#if providers.google || providers.osu}
<div class="my-6 flex flex-col gap-3 border-t border-[var(--site-border)] pt-4">
{#if providers.google}<button
disabled={!ready || busy}
onclick={() => social('google')}
class="rounded border border-[var(--site-border)] px-4 py-2 disabled:opacity-50"
>Continue with Google</button
>{/if}
{#if providers.osu}<button
disabled={!ready || busy}
onclick={() => social('osu')}
class="rounded border border-[var(--site-border)] px-4 py-2 disabled:opacity-50"
>Continue with osu!</button
>{/if}
</div>
{/if}
<p class="mt-6 text-base">
<a
class="underline underline-offset-4"
href={resolve(
`${mode === 'register' ? '/login' : '/register'}?returnTo=${encodeURIComponent(returnTo)}`
)}>{mode === 'register' ? 'sign in' : 'Create account'}</a
>
</p>
<noscript><p class="mt-4">Enable JavaScript to securely sign in or register.</p></noscript>
</section>

View File

@@ -33,7 +33,7 @@
<button
onclick={copy}
aria-label={`Copy ${label}: ${value}`}
class="text-2xl transition hover:opacity-60 cursor-pointer"
class="flex h-10 w-10 cursor-pointer items-center justify-center text-xl transition hover:text-[var(--site-text)]"
>
<Icon />
</button>

View File

@@ -0,0 +1,64 @@
<script lang="ts">
import { onMount } from 'svelte';
let { label = 'Picture (optional)' }: { label?: string } = $props();
let input: HTMLInputElement;
let fileName = $state('');
let dragging = $state(false);
function selected() {
fileName = input.files?.[0]?.name ?? '';
}
function drop(event: DragEvent) {
event.preventDefault();
dragging = false;
const file = event.dataTransfer?.files?.[0];
if (!file) return;
const transfer = new DataTransfer();
transfer.items.add(file);
input.files = transfer.files;
selected();
}
function clear() {
input.value = '';
fileName = '';
}
onMount(() => {
const form = input.form;
form?.addEventListener('reset', clear);
return () => form?.removeEventListener('reset', clear);
});
</script>
<div>
<span class="block font-medium">{label}</span>
<input
bind:this={input}
name="picture"
type="file"
accept="image/jpeg,image/png,image/webp"
onchange={selected}
class="sr-only"
aria-label={label}
/>
<button
type="button"
onclick={() => input.click()}
ondragover={(event) => {
event.preventDefault();
dragging = true;
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
}}
ondragleave={() => (dragging = false)}
ondrop={drop}
class={`mt-2 block w-full rounded border border-dashed p-5 text-center transition-colors ${dragging ? 'border-[var(--site-text)] bg-[var(--site-hover)]' : 'border-[var(--site-border)] hover:border-[var(--site-muted)]'}`}
>
{fileName ? `Selected: ${fileName}` : 'Drop an image here or click to browse'}
</button>
<p class="mt-2 text-base text-[var(--site-muted)]">JPEG, PNG, or WebP · up to 5 MB</p>
{#if fileName}<button type="button" onclick={clear} class="mt-2 underline">Clear selection</button
>{/if}
</div>

View File

@@ -0,0 +1,43 @@
<script lang="ts">
let {
value,
max,
label,
small = false
}: {
value: number;
max: number;
label: string;
small?: boolean;
} = $props();
</script>
<progress class:small {value} max={max || 1} aria-label={label}></progress>
<style>
progress {
display: block;
width: 100%;
height: 0.625rem;
appearance: none;
border: 0;
border-radius: 9999px;
overflow: hidden;
background: var(--site-border);
}
progress.small {
height: 0.375rem;
}
progress::-webkit-progress-bar {
background: var(--site-border);
border-radius: 9999px;
}
progress::-webkit-progress-value {
background: var(--site-text);
border-radius: 9999px;
}
progress::-moz-progress-bar {
background: var(--site-text);
border-radius: 9999px;
}
</style>

View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { defaultsFor, gPredict } from './generic';
import { placings } from './model';
import { races } from './races';
describe('generic race model', () => {
for (const race of races) {
it(`${race.slug}: probabilities sum to one and placings are consistent`, () => {
const preds = gPredict(race.runners, defaultsFor(race));
expect(preds.reduce((a, r) => a + r.p, 0)).toBeCloseTo(1, 9);
expect(preds.length).toBe(race.runners.length);
const { top, trio } = placings(preds, 0.8);
expect(top.reduce((a, t) => a + t.win, 0)).toBeCloseTo(1, 9);
expect(trio.reduce((a, t) => a + t.p, 0)).toBeCloseTo(1, 9);
for (const r of preds) expect(Number.isFinite(r.logit)).toBe(true);
});
}
it('debutants get the field average and a higher fail risk', () => {
const race = races.find((r) => r.slug === 'south-holland')!;
const preds = gPredict(race.runners, defaultsFor(race));
const deb = preds.find((r) => r.horse.n === 1)!;
expect(deb.features.estimated).toEqual(['form', 'speed', 'level']);
expect(deb.features.pFail).toBeCloseTo(0.12);
});
});

132
src/lib/derby/generic.ts Normal file
View File

@@ -0,0 +1,132 @@
// Algemeen kansmodel voor de overige koersen: zelfde opzet als het Derby-model
// (z-scores × gewichten → softmax), maar zonder proef- en marktsignaal.
// Signalen: uitslagen, snelheid (beste recente km-tijd), niveau (prijzengeld
// van de laatste starts), startpositie en uitvalrisico.
import type { Result } from './data';
import type { Race, Runner } from './races';
export type GWeights = {
form: number;
speed: number;
level: number;
draw: number;
risk: number;
halfLife: number;
temperature: number;
lambda: number;
};
export const gFeatureKeys = ['form', 'speed', 'level', 'draw'] as const;
export type GFeatureKey = (typeof gFeatureKeys)[number];
export const gDefaults: GWeights = {
form: 1,
speed: 1,
level: 0.6,
draw: 0.4,
risk: 1,
halfLife: 2,
temperature: 1,
lambda: 0.8
};
/** rensport: geen tijden, startbox minder belangrijk over 1900 m */
export const defaultsFor = (race: Race): GWeights =>
race.discipline === 'ren' ? { ...gDefaults, speed: 0, draw: 0.2, level: 0.8 } : { ...gDefaults };
export const gPresets: Record<string, Partial<GWeights>> = {
Standaard: {},
Vorm: { form: 1.6, speed: 0.5, level: 0.4 },
Snelheid: { form: 0.5, speed: 1.6, level: 0.4 },
Niveau: { form: 0.6, speed: 0.6, level: 1.5 }
};
const SCALE = 0.55;
const POSITION_POINTS: Record<number, number> = { 1: 1, 2: 0.75, 3: 0.6, 4: 0.45, 5: 0.35, 6: 0.25 };
const positionPoints = (r: Result) => (r === 'D' ? 0 : (POSITION_POINTS[r] ?? 0.1));
const DRAW_SCORE = [0, 0.6, 0.7, 0.6, 0.45, 0.4, 0.25, 0.1, 0, -0.2, -0.3];
export type GFeatures = {
form: number;
speed: number;
level: number;
draw: number;
pFail: number;
/** welke signalen ontbraken en het veldgemiddelde kregen */
estimated: GFeatureKey[];
};
const mean = (xs: number[]) => xs.reduce((a, b) => a + b, 0) / xs.length;
function rawForm(h: Runner, w: GWeights): number | null {
if (h.form.length === 0) return null;
const decay = h.form.map((_, i) => Math.pow(0.5, i / w.halfLife));
return h.form.reduce((s: number, r, i) => s + decay[i] * positionPoints(r), 0) / decay.reduce((a, b) => a + b, 0);
}
export function gFeatures(runners: Runner[], w: GWeights): GFeatures[] {
const raw = runners.map((h) => ({
form: rawForm(h, w),
speed: h.times.length ? -Math.min(...h.times) : null,
level: h.purses.length ? mean(h.purses.map((p) => Math.log(p))) : null
}));
const fill = (k: 'form' | 'speed' | 'level') => {
const known = raw.map((r) => r[k]).filter((v): v is number => v !== null);
return known.length ? mean(known) : 0;
};
const avg = { form: fill('form'), speed: fill('speed'), level: fill('level') };
return runners.map((h, i) => {
const estimated: GFeatureKey[] = [];
const pick = (k: 'form' | 'speed' | 'level') => {
const v = raw[i][k];
if (v === null) estimated.push(k);
return v ?? avg[k];
};
const disq = h.form.filter((r) => r === 'D').length;
const poor = h.form.filter((r) => r !== 'D' && r >= 7).length;
// debutanten: geen bewijs dat ze in de draf blijven
const base = h.form.length === 0 ? 0.12 : 0.03;
const pFail = Math.min(0.5, base + 0.1 * disq + 0.03 * poor);
return { form: pick('form'), speed: pick('speed'), level: pick('level'), draw: DRAW_SCORE[h.n] ?? 0, pFail, estimated };
});
}
/**
* ondergrens voor de spreiding per signaal, anders wordt 0,2 s verschil in een
* veld met drie bijna gelijke tijden een enorme z-score
*/
const SD_FLOOR: Record<GFeatureKey, number> = { form: 0.08, speed: 1, level: 0.3, draw: 0.15 };
function zscores(values: number[], floor: number): number[] {
const m = mean(values);
const sd = Math.max(floor, Math.sqrt(mean(values.map((v) => (v - m) ** 2))));
return values.map((v) => (v - m) / sd);
}
export type GPrediction = {
horse: Runner;
features: GFeatures;
contributions: Record<GFeatureKey, number>;
riskPenalty: number;
logit: number;
p: number;
};
export function gPredict(runners: Runner[], w: GWeights): GPrediction[] {
if (runners.length === 0) return [];
const feats = gFeatures(runners, w);
const z = Object.fromEntries(gFeatureKeys.map((k) => [k, zscores(feats.map((f) => f[k]), SD_FLOOR[k])])) as Record<GFeatureKey, number[]>;
const rows = runners.map((horse, i) => {
const contributions = Object.fromEntries(gFeatureKeys.map((k) => [k, w[k] * z[k][i] * SCALE])) as Record<GFeatureKey, number>;
const riskPenalty = w.risk * Math.log(1 - feats[i].pFail);
const logit = Object.values(contributions).reduce((a, b) => a + b, 0) + riskPenalty;
return { horse, features: feats[i], contributions, riskPenalty, logit, p: 0 };
});
const max = Math.max(...rows.map((r) => r.logit));
const exp = rows.map((r) => Math.exp((r.logit - max) / w.temperature));
const total = exp.reduce((a, b) => a + b, 0);
rows.forEach((r, i) => (r.p = exp[i] / total));
return rows.sort((a, b) => b.p - a.p);
}

View File

@@ -129,7 +129,7 @@ export type Placings = {
* Stern-demping: voor de 2e en 3e plaats wordt p^lambda gebruikt, wat de
* neiging van pure PL corrigeert om de favoriet te vaak tweede te laten worden.
*/
export function placings(preds: Prediction[], lambda: number): Placings {
export function placings(preds: { p: number }[], lambda: number): Placings {
const n = preds.length;
const p1 = preds.map((r) => r.p);
const p2 = p1.map((p) => Math.pow(p, lambda));

128
src/lib/derby/races.ts Normal file
View File

@@ -0,0 +1,128 @@
// Duindigt · zondag 20 september 2026 · koersen 3, 4 en 5
// Bron: ZEturf koerskaarten en paardenfiches (laatste starts, kilometertijden, prijzengeld).
import type { Result } from './data';
export type Runner = {
n: number;
name: string;
driver: string;
trainer: string;
sex: 'M' | 'H' | 'R';
age: number;
/** laatste vijf uitslagen, nieuwste eerst; D = gediskwalificeerd/gevallen; leeg = debutant */
form: Result[];
/** kilometertijden (s) van de laatste starts, nieuwste eerst; alleen bekende */
times: number[];
/** prijzengeld (€) van de laatste starts, nieuwste eerst: niveau waarop het paard uitkomt */
purses: number[];
/** beste kilometertijd ooit, null = onbekend */
record: number | null;
/** gedragen gewicht (alleen rensport) */
kg?: number;
note?: string;
};
export type Race = {
slug: string;
number: number;
name: string;
short: string;
time: string;
distance: number;
start: string;
purse: number;
surface: string;
discipline: 'draf' | 'ren';
conditions: string;
/** voorbeschouwing ZEturf (Kees Nakoersen) */
preview: string;
tips: number[];
runners: Runner[];
sources: { label: string; href: string }[];
};
const t = (m: number, s: number) => m * 60 + s;
const ze = (c: number, slug: string) => `https://www.zeturf.be/nl/course-du-jour/2026-09-20/R14C${c}-duindigt-${slug}`;
export const races: Race[] = [
{
slug: 'sheikh-mansour',
number: 3,
name: 'HH Sheikh Mansour bin Zayed Al Nahyan Prijs',
short: 'Sheikh Mansour Prijs',
time: '16:10',
distance: 1900,
start: 'Startboxen',
purse: 10000,
surface: 'Grasbaan · standaard',
discipline: 'ren',
conditions: 'Ren voor Arabische volbloeds, 4 jaar en ouder',
preview:
'Tex D.A. (3) kan mogelijk op herhaling op deze baan waar hij op 26 juli sterk won voor tweede Ektor D.A. (6) en met als vierde Orszana JR (5). Maar de ruin treft in El Zarka D.A. (1) en Farida P (2) twee sterke tegenstanders. Beide bleven weliswaar op iets meer dan 8 lengte op deze baan ongeplaatst, maar dat was tegen de top van Europa. Princess T (4) versloeg Tex D.A. met een neusje verschil op de grasbaan van Joure, dus ook deze merrie moeten we serieus nemen. Een koers waarin veel mogelijk is.',
tips: [3, 1, 2, 6],
sources: [{ label: 'ZEturf koerskaart', href: ze(3, 'hh-sheikh-mansour-bin-zayed-al-nahyan-prijs') }],
runners: [
{ n: 1, name: 'El Zarka D.A.', driver: 'Jurico Bogaerts', trainer: 'Johan Verstrepen', sex: 'R', age: 12, form: [7, 9, 5, 5, 3], times: [], purses: [250000, 100000, 26400, 92764, 6000], record: null, kg: 64, note: 'loopt tegen de Europese top' },
{ n: 2, name: 'Farida P', driver: 'Anne Engels', trainer: 'Peter Deckers', sex: 'M', age: 7, form: ['D', 10, 3, 3, 1], times: [], purses: [250000, 100000, 26400, 92764, 6000], record: null, kg: 62, note: 'D = gevallen in Waregem' },
{ n: 3, name: 'Tex D.A.', driver: 'Luca van Toor', trainer: 'Bianca Dunk', sex: 'R', age: 9, form: [10, 1, 2, 2, 2], times: [], purses: [250000, 5000, 4000, 6000, 6000], record: null, kg: 61, note: 'won 26 juli op Duindigt' },
{ n: 4, name: 'Princess T', driver: 'Mirko Sanna', trainer: 'Gerard Zoetelief', sex: 'M', age: 6, form: [1, 4, 3, 5, 4], times: [], purses: [4000, 6000, 4000, 6000, 6000], record: null, kg: 57, note: 'versloeg Tex D.A. in Joure' },
{ n: 5, name: 'Orszana JR', driver: 'Josephine Bruning', trainer: "Manouk van 't Slot", sex: 'M', age: 8, form: [4, 4, 4, 7], times: [], purses: [5000, 4000, 4200, 5100], record: null, kg: 56 },
{ n: 6, name: 'Ektor D.A.', driver: 'Siem Bos', trainer: 'Johan Verstrepen', sex: 'H', age: 4, form: [3, 2, 5, 3, 7], times: [], purses: [6000, 5000, 6000, 6000, 7000], record: null, kg: 55 }
]
},
{
slug: 'schieweg',
number: 4,
name: 'Handelsonderneming Schieweg B.V. Prijs',
short: 'Schieweg Prijs',
time: '16:40',
distance: 2000,
start: 'Autostart',
purse: 4750,
surface: 'Sintelbaan · goed',
discipline: 'draf',
conditions: 'Draverij voor 4 jaar en ouder, winsom lager dan € 18.000',
preview:
'Paradepaard van Duindigt 2025 Louis Vuiton (7) wordt na deze koers gehuldigd, maar of dat als winnaar is staat niet vast. Pasion d’Oro (4) is waarschijnlijk het te kloppen paard: de hengst van Jacky Mollema verkeert in topvorm. Michel D (6) is eveneens in grote vorm, won tweemaal in Duitsland maar nog niet op Duindigt, al zat hij daar steeds dichtbij. Nobell Starro (5) werd in zijn laatste koers vierde op één lijn met tweede aankomende Louis Vuiton. Nolana Boko (8) won dit jaar al driemaal, laatst in Alkmaar. Otini Bianco (2) is hoogstwaarschijnlijk het Paradepaard van Duindigt 2026; met een juiste koers is de ruin er vandaag ook bij.',
tips: [4, 7, 6, 5],
sources: [{ label: 'ZEturf koerskaart', href: ze(4, 'handelsonderneming-schieweg-b-v-prijs') }],
runners: [
{ n: 1, name: 'Puck Ballaco', driver: 'Micha Brouwer', trainer: 'Rob de Vlieger', sex: 'M', age: 4, form: [4, 4, 1, 4, 3], times: [t(1, 17.9), t(1, 16.3), t(1, 16.6), t(1, 16.5), t(1, 17.8)], purses: [2750, 4000, 4000, 4400, 1100], record: t(1, 13.0) },
{ n: 2, name: 'Otini Bianco', driver: 'Rob de Vlieger', trainer: 'Rob de Vlieger', sex: 'R', age: 5, form: ['D', 6, 1, 2, 'D'], times: [t(1, 15.5), t(1, 15.9), t(1, 16.4)], purses: [2750, 4400, 2750, 2750, 2750], record: t(1, 14.7), note: 'won 31 juli op Duindigt' },
{ n: 3, name: 'Memory de Legume', driver: 'Patrick de Haan', trainer: 'Patrick de Haan', sex: 'M', age: 7, form: [6, 7, 8, 4, 4], times: [t(1, 19.7), t(1, 16.9), t(1, 15.6), t(1, 18.2), t(1, 14.1)], purses: [1600, 4400, 4400, 1500, 2200], record: t(1, 14.1) },
{ n: 4, name: "Pasion d'Oro", driver: 'Jaap van Rijn', trainer: 'Jack Mollema', sex: 'H', age: 4, form: [1, 5, 1, 1, 5], times: [t(1, 16.6), t(1, 11.6), t(1, 12.2), t(1, 21.1), t(1, 14.0)], purses: [2750, 19567, 2200, 1650, 13806], record: t(1, 11.6), note: 'liep in Zweden tegen sterkere' },
{ n: 5, name: 'Nobell Starro', driver: 'Robin Bakker', trainer: 'Fons Bakker', sex: 'R', age: 6, form: [4, 2, 1, 2, 'D'], times: [t(1, 14.0), t(1, 16.2), t(1, 11.1)], purses: [2750, 5000, 2000, 2200, 4400], record: t(1, 11.1) },
{ n: 6, name: 'Michel D', driver: 'Jim Veldman', trainer: 'Huug van Dijk', sex: 'H', age: 7, form: [3, 2, 1, 2, 1], times: [t(1, 20.2), t(1, 15.6), t(1, 14.1), t(1, 16.9), t(1, 14.9)], purses: [5000, 2750, 2000, 5500, 1500], record: t(1, 13.8), note: 'laatste tijd over 3375 m' },
{ n: 7, name: 'Louis Vuiton', driver: 'Hugo Langeweg', trainer: 'Hugo Langeweg', sex: 'R', age: 8, form: [2, 5, 4, 'D', 1], times: [t(1, 17.8), t(1, 12.1), t(1, 16.9)], purses: [2750, 11000, 2200, 1650, 2750], record: t(1, 12.1), note: 'Paradepaard 2025' },
{ n: 8, name: 'Nolana Boko', driver: 'John Dekker', trainer: 'Carel Roodenrijs', sex: 'R', age: 6, form: [1, 'D', 5, 4, 4], times: [t(1, 19.1), t(1, 20.9), t(1, 13.9), t(1, 23.0)], purses: [1600, 2750, 1100, 2000, 1210], record: t(1, 13.4), note: 'vaak op tragere banen (Alkmaar)' }
]
},
{
slug: 'south-holland',
number: 5,
name: 'South Holland Prijs · TCT Tweejarigen Satellietkoers',
short: 'South Holland Prijs',
time: '17:10',
distance: 1609,
start: 'Autostart',
purse: 4000,
surface: 'Sintelbaan · goed',
discipline: 'draf',
conditions: 'Draverij voor tweejarigen (sprint, 1609 m)',
preview:
'Sisi Hazelaar (5) is op basis van haar laatste tweede plaats in Wolvega, waar ze een puike indruk maakte, kansrijk. Zo ook Seabiscuit Lobell (4), die vorige week bij haar debuut tweede werd achter Salina Meerswal; met een fijn stuk speed kan ze binnendoor plek twee veroveren. Spirit Meerswal (2) debuteerde met een derde plaats in Wolvega in 15,2 en is kanshebber nummer drie. Sophie Starlake (1) debuteert voor de stal van Dion Tesselaar, en dan zijn we sowieso gewaarschuwd.',
tips: [5, 4, 2, 1],
sources: [{ label: 'ZEturf koerskaart', href: ze(5, 'south-holland-prijs-tct-tweejarigen-satellietkoers') }],
runners: [
{ n: 1, name: 'Sophie Starlake', driver: 'Dion Tesselaar', trainer: 'Dion Tesselaar', sex: 'M', age: 2, form: [], times: [], purses: [], record: null, note: 'debutant · stal Tesselaar' },
{ n: 2, name: 'Spirit Meerswal', driver: 'Jaap van Rijn', trainer: 'Jack Mollema', sex: 'H', age: 2, form: [3], times: [t(1, 15.2)], purses: [5500], record: t(1, 15.2) },
{ n: 3, name: 'Shiva Jabi', driver: 'Jan Baas', trainer: 'Jan Baas', sex: 'H', age: 2, form: ['D', 'D'], times: [], purses: [3300, 5500], record: null, note: 'twee keer uit de draf' },
{ n: 4, name: 'Seabiscuit Lobell', driver: 'Hugo Langeweg', trainer: 'Hugo Langeweg', sex: 'M', age: 2, form: [2], times: [t(1, 15.2)], purses: [3300], record: t(1, 15.2), note: '2e bij debuut, 10 september' },
{ n: 5, name: 'Sisi Hazelaar', driver: 'Jeffrey Mieras', trainer: 'Dion Tesselaar', sex: 'M', age: 2, form: [2, 'D'], times: [t(1, 15.0)], purses: [5500, 5500], record: t(1, 15.0) },
{ n: 6, name: 'Sir Bianco', driver: 'Frans van der Blonk', trainer: 'Frans van der Blonk', sex: 'R', age: 2, form: [], times: [], purses: [], record: null, note: 'debutant' }
]
}
];
export const raceBySlug = (slug: string) => races.find((r) => r.slug === slug);

10
src/lib/geo.test.ts Normal file
View File

@@ -0,0 +1,10 @@
import { describe, expect, it } from 'vitest';
import { distanceMeters } from './geo';
describe('distanceMeters', () => {
it('keeps nearby sightings within the 30 km limit and rejects distant ones', () => {
expect(distanceMeters(52.009, 4.708, 52.009, 4.708)).toBe(0);
expect(distanceMeters(52.009, 4.708, 52.01, 4.71)).toBeLessThan(30_000);
expect(distanceMeters(52.009, 4.708, 52.37, 4.9)).toBeGreaterThan(30_000);
});
});

11
src/lib/geo.ts Normal file
View File

@@ -0,0 +1,11 @@
const EARTH_RADIUS_METERS = 6_371_000;
export function distanceMeters(lat1: number, lng1: number, lat2: number, lng2: number): number {
const toRad = (degrees: number) => (degrees * Math.PI) / 180;
const deltaLat = toRad(lat2 - lat1);
const deltaLng = toRad(lng2 - lng1);
const a =
Math.sin(deltaLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(deltaLng / 2) ** 2;
return 2 * EARTH_RADIUS_METERS * Math.asin(Math.sqrt(a));
}

View File

@@ -0,0 +1,23 @@
<script lang="ts">
let { src, class: className = '' }: { src?: string; class?: string } = $props();
let failed = $state(false);
$effect(() => {
void src;
failed = false;
});
</script>
<div class={`overflow-hidden rounded bg-gray-200 ${className}`}>
{#if src?.startsWith('https://') && !failed}<img
{src}
alt=""
class="h-full w-full object-cover"
loading="lazy"
onerror={() => (failed = true)}
/>{:else}<div
class="flex h-full items-center justify-center text-2xl text-gray-500"
aria-hidden="true"
>
♫
</div>{/if}
</div>

View File

@@ -0,0 +1,26 @@
<script lang="ts">
import type { ScoredBeatmapset } from './types';
import Artwork from './Artwork.svelte';
let { map }: { map: ScoredBeatmapset } = $props();
</script>
<a
href={`https://osu.ppy.sh/beatmapsets/${map.id}`}
target="_blank"
rel="noopener noreferrer"
class="block h-full overflow-hidden rounded border border-gray-300 hover:border-blue-600 focus-visible:outline-2 focus-visible:outline-blue-600"
>
<Artwork src={map.covers?.card} class="h-24 rounded-none" />
<div class="space-y-1 p-3">
<h4 class="font-semibold">
{map.artist_unicode || map.artist} – {map.title_unicode || map.title}
</h4>
<p class="text-sm text-gray-600">{map.score}/100 · {map.confidence} confidence</p>
<p class="text-xs text-gray-600">{map.reasons.join(' · ')}</p>
{#if map.status || map.beatmaps}<p class="text-xs text-gray-600">
{map.status ?? ''}{map.status && map.beatmaps ? ' · ' : ''}{map.beatmaps
? `${map.beatmaps.length} difficulties`
: ''}
</p>{/if}
</div>
</a>

View File

@@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest';
import { parseSpotifyInput } from './input';
import { normalize, cleanTitle, scoreBeatmapset, rankCandidates, MAX_CANDIDATES } from './matching';
import type { SpotifyTrack } from './types';
const id = '6PUzxtIHkv346yP89NzP9X';
const track: SpotifyTrack = {
id,
name: 'Song Title',
artists: [{ name: 'Beyoncé & Friends' }],
album: { name: 'Album', images: [] }
};
const map = { id: 1, title: 'Song Title', artist: 'Beyonce and Friends' };
describe('Spotify input', () => {
it.each([
`https://open.spotify.com/track/${id}`,
` https://open.spotify.com/track/${id}?si=abc `,
`http://www.open.spotify.com/track/${id}/`,
`https://open.spotify.com/intl-nl/track/${id}/?si=abc`,
`spotify:track:${id}`
])('parses %s', (value) => expect(parseSpotifyInput(value)).toEqual({ kind: 'track', id }));
it.each([`spotify:playlist:${id}`, `https://open.spotify.com/playlist/${id}?si=abc`])(
'parses playlist %s',
(value) => expect(parseSpotifyInput(value)).toEqual({ kind: 'playlist', id })
);
it.each(['artist', 'episode', 'show'])('rejects unsupported %s', (resource) =>
expect(parseSpotifyInput(`https://open.spotify.com/${resource}/${id}`).kind).toBe('unsupported')
);
it.each([
'',
' ',
'spotify:track:bad',
'https://open.spotify.com/track/',
'https://open.spotify.com/track/bad',
'x'.repeat(501)
])('validates %s', (value) => expect(() => parseSpotifyInput(value)).toThrow());
it('preserves plain search and rejects lookalike host recognition', () => {
expect(parseSpotifyInput(' Deftones Change ')).toEqual({
kind: 'search',
query: 'Deftones Change'
});
expect(parseSpotifyInput(`https://open.spotify.com.evil.test/track/${id}`).kind).toBe('search');
});
});
describe('matching', () => {
it.each([
'Song Title',
'Song Title (Remastered 2011)',
'Song Title - Remaster',
'Song Title (feat. Someone)',
'Song Title [Radio Edit]',
'Song Title - Album Version'
])('cleans %s', (value) => expect(cleanTitle(value)).toBe('song title'));
it('normalizes punctuation, diacritics, ampersands and whitespace', () =>
expect(normalize(' Beyoncé & Friends! ')).toBe('beyonce and friends'));
it('keeps non-Latin text', () => expect(normalize('世界の歌')).toBe('世界の歌'));
it('scores exact and unicode aliases highly', () => {
expect(scoreBeatmapset(track, map).score).toBe(100);
expect(
scoreBeatmapset(
{ ...track, name: '世界の歌' },
{ ...map, title: 'Sekai no uta', title_unicode: '世界の歌' }
).confidence
).toBe('high');
});
it('does not confidently match the wrong artist', () =>
expect(scoreBeatmapset(track, { ...map, artist: 'Unrelated Singer' }).confidence).toBe('low'));
it('penalizes remix mismatch', () =>
expect(scoreBeatmapset(track, { ...map, title: 'Song Title (Club Remix)' }).confidence).toBe(
'low'
));
it('sorts, merges duplicate IDs and limits results', () => {
const candidates = rankCandidates(track, [
{ ...map, title: 'Song Title remix' },
...Array.from({ length: 10 }, (_, id) => ({ ...map, id }))
]);
expect(candidates).toHaveLength(MAX_CANDIDATES);
expect(new Set(candidates.map((m) => m.id)).size).toBe(MAX_CANDIDATES);
expect(candidates[0].score).toBe(100);
expect(candidates.every((m, i) => !i || candidates[i - 1].score >= m.score)).toBe(true);
});
it('discards unrelated candidates', () =>
expect(
rankCandidates(track, [{ ...map, title: 'Nothing Alike', artist: 'Different' }])
).toEqual([]));
});
it.each([`spotify:album:${id}`, `https://www.open.spotify.com/intl-nl/album/${id}/?si=abc`])(
'parses album %s',
(value) => expect(parseSpotifyInput(value)).toEqual({ kind: 'album', id })
);

View File

@@ -0,0 +1,44 @@
export type SpotifyInput =
| { kind: 'track'; id: string }
| { kind: 'playlist'; id: string }
| { kind: 'album'; id: string }
| { kind: 'search'; query: string }
| { kind: 'unsupported'; resource: string };
export function parseSpotifyInput(value: string): SpotifyInput {
const query = value.trim();
if (!query || query.length > 500)
throw new Error('Enter a Spotify URL, URI, or song search (up to 500 characters).');
let resource: string | undefined;
let id: string | undefined;
if (/^spotify:/i.test(query)) {
const match = /^spotify:([^:]+):([^:]+)$/i.exec(query);
if (!match)
throw new Error(
'Invalid Spotify URI. Use spotify:track:ID or spotify:playlist:ID or spotify:album:ID.'
);
[, resource, id] = match;
} else {
let url: URL;
try {
url = new URL(query);
} catch {
if (/^(?:https?:\/\/)?(?:www\.)?open\.spotify\.com\b/i.test(query))
throw new Error('Invalid Spotify URL. Include https://open.spotify.com/.');
return { kind: 'search', query };
}
if (!/^(www\.)?open\.spotify\.com$/i.test(url.hostname)) return { kind: 'search', query };
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.port)
throw new Error('Invalid Spotify URL.');
const match = /^\/(?:intl-[a-z-]+\/)?([^/]+)(?:\/([^/]+))?\/?$/i.exec(url.pathname);
if (!match) throw new Error('Invalid Spotify URL path.');
[, resource, id] = match;
}
resource = resource.toLowerCase();
if (resource !== 'track' && resource !== 'playlist' && resource !== 'album')
return { kind: 'unsupported', resource };
if (!id || !/^[a-zA-Z0-9]{22}$/.test(id))
throw new Error(
'Invalid Spotify ID. Track, playlist, and album IDs must contain 22 letters or digits.'
);
return { kind: resource, id };
}

View File

@@ -0,0 +1,87 @@
import type { OsuBeatmapset, ScoredBeatmapset, SpotifyTrack } from './types';
// Medium requires a strong title plus artist evidence. High is reserved for near-exact pairs.
export const HIGH_CONFIDENCE = 88;
export const MEDIUM_CONFIDENCE = 72;
export const MIN_CANDIDATE_SCORE = 40;
export const MAX_CANDIDATES = 6;
export function normalize(value: string): string {
return value
.normalize('NFKD')
.replace(/\p{M}/gu, '')
.toLowerCase()
.replace(/&/g, ' and ')
.replace(/[^\p{L}\p{N}\s]/gu, ' ')
.replace(/\s+/g, ' ')
.trim();
}
export function cleanTitle(value: string): string {
return normalize(
value
.replace(
/\s*[([](?:feat\.?|ft\.?|featuring|[^)\]]*(?:remaster|radio edit|album version|soundtrack|from the))[^)\]]*[)\]]/gi,
''
)
.replace(/\s+(?:feat\.?|ft\.?|featuring)\s+.*$/i, '')
.replace(/\s*[-–—]\s*(?:\d{4}\s*)?(?:remaster\w*|radio edit|album version|from\s+).*$/i, '')
);
}
function similarity(a: string, b: string): number {
if (!a || !b) return 0;
if (a === b) return 1;
const left = new Set(a.split(' ')),
right = new Set(b.split(' '));
const overlap = [...left].filter((token) => right.has(token)).length;
return Math.max(
(2 * overlap) / (left.size + right.size),
a.length >= 5 && b.length >= 5 && (a.includes(b) || b.includes(a)) ? 0.8 : 0
);
}
function versions(value: string): string {
const normalized = normalize(value);
return ['remix', 'live', 'acoustic', 'instrumental', 'sped up', 'nightcore']
.filter((word) => normalized.includes(word))
.join(' ');
}
export function scoreBeatmapset(track: SpotifyTrack, map: OsuBeatmapset): ScoredBeatmapset {
const title = Math.max(
...[map.title, map.title_unicode ?? ''].map((v) =>
similarity(cleanTitle(track.name), cleanTitle(v))
)
);
const artistNames = [
...track.artists.map((a) => a.name),
track.artists.map((a) => a.name).join(' and ')
];
const artist = Math.max(
0,
...artistNames.flatMap((a) =>
[map.artist, map.artist_unicode ?? ''].map((b) => similarity(normalize(a), normalize(b)))
)
);
const mismatch = versions(track.name) !== versions(map.title + ' ' + (map.title_unicode ?? ''));
let score = Math.round(title * 65 + artist * 35 - (mismatch ? 24 : 0));
if (artist < 0.3) score = Math.min(score, MEDIUM_CONFIDENCE - 1);
score = Math.max(0, score);
const reasons = [
title === 1 ? 'title exact' : 'title similar',
artist === 1 ? 'artist exact' : artist >= 0.3 ? 'artist similar' : 'artist differs'
];
if (mismatch) reasons.push('version differs');
return {
...map,
score,
confidence: score >= HIGH_CONFIDENCE ? 'high' : score >= MEDIUM_CONFIDENCE ? 'medium' : 'low',
reasons
};
}
export function rankCandidates(track: SpotifyTrack, maps: OsuBeatmapset[]): ScoredBeatmapset[] {
const merged = new Map<number, ScoredBeatmapset>();
for (const map of maps) {
const scored = scoreBeatmapset(track, map);
if (scored.score > (merged.get(map.id)?.score ?? -1)) merged.set(map.id, scored);
}
return [...merged.values()]
.filter((m) => m.score >= MIN_CANDIDATE_SCORE)
.sort((a, b) => b.score - a.score || a.id - b.id)
.slice(0, MAX_CANDIDATES);
}

View File

@@ -0,0 +1,34 @@
export type SpotifyTrack = {
id: string;
name: string;
artists: { name: string }[];
album: { name: string; images: { url: string }[] };
};
export type SpotifyCollection = {
kind: 'playlist' | 'album';
id: string;
name: string;
owner: string;
image?: string;
};
export type OsuBeatmapset = {
id: number;
artist: string;
title: string;
artist_unicode?: string;
title_unicode?: string;
covers?: { card?: string; 'card@2x'?: string };
status?: string;
beatmaps?: { id: number }[];
};
export type ScoredBeatmapset = OsuBeatmapset & {
score: number;
confidence: 'high' | 'medium' | 'low';
reasons: string[];
};
export type TrackMatchResult = {
track: SpotifyTrack;
matches: ScoredBeatmapset[];
status: 'matched' | 'no_match' | 'error';
error?: string;
};

View File

@@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest';
import { relativeTime } from './relative-time';
describe('relativeTime', () => {
it('uses readable singular and plural dates', () => {
const now = Date.UTC(2026, 8, 23);
expect(relativeTime(new Date(now - 1000), now)).toBe('just now');
expect(relativeTime(new Date(now - 1000 * 30), now)).toBe('30 seconds ago');
expect(relativeTime(new Date(now - 1000 * 60 * 60), now)).toBe('an hour ago');
expect(relativeTime(new Date(now - 1000 * 60 * 60 * 24 * 365), now)).toBe('a year ago');
});
});

26
src/lib/relative-time.ts Normal file
View File

@@ -0,0 +1,26 @@
export function relativeTime(date: Date | string, now: number) {
const elapsed = Math.max(0, Math.floor((now - new Date(date).getTime()) / 1000));
if (elapsed < 5) return 'just now';
const units: [number, string][] = [
[31_536_000, 'year'],
[2_592_000, 'month'],
[604_800, 'week'],
[86_400, 'day'],
[3_600, 'hour'],
[60, 'minute'],
[1, 'second']
];
for (const [seconds, unit] of units) {
if (elapsed >= seconds) {
const count = Math.floor(elapsed / seconds);
return `${count === 1 ? (unit === 'hour' ? 'an' : 'a') : count} ${unit}${count === 1 ? '' : 's'} ago`;
}
}
return 'just now';
}
const dateFormatter = new Intl.DateTimeFormat('en', { dateStyle: 'long', timeZone: 'UTC' });
export function calendarDate(date: Date | string) {
return dateFormatter.format(new Date(date));
}

54
src/lib/seo.test.ts Normal file
View File

@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';
import { getSeo } from './seo';
describe('public page metadata', () => {
it('gives the homepage a descriptive title and production canonical URL', () => {
const seo = getSeo('/');
expect(seo.title).toBe('Projects, lists and writing | Stan Runge');
expect(seo.url).toBe('https://stanrunge.dev/');
expect(seo.indexable).toBe(true);
expect(seo.image).toBe('https://stanrunge.dev/moon.jpg');
});
it('uses article content and its latest edit time', () => {
const article = getSeo('/blog/my-post', {
blogArticle: {
title: 'My post',
content: 'A **specific** article about lists.',
updatedAt: '2026-09-02T00:00:00.000Z'
}
});
expect(article.title).toBe('My post | Stan Runge');
expect(article.description).toBe('A specific article about lists.');
expect(article.type).toBe('article');
expect(article.modifiedAt).toBe('2026-09-02T00:00:00.000Z');
});
it('describes a list using its contents', () => {
const seo = getSeo('/lists/vinyls-to-collect', {
list: {
name: 'Vinyls to collect',
isProgress: true,
items: [{ name: 'Album one' }, { name: 'Album two' }]
}
});
expect(seo.title).toBe('Vinyls to collect | Stan Runge');
expect(seo.description).toContain('Album one, Album two');
expect(seo.indexable).toBe(true);
});
it('keeps private and staging pages out of search results', () => {
expect(getSeo('/login').indexable).toBe(false);
expect(getSeo('/projects/animaldex/register').indexable).toBe(false);
expect(
getSeo('/blog/my-post/edit', {
blogArticle: {
title: 'My post',
content: '',
updatedAt: ''
}
}).indexable
).toBe(false);
expect(getSeo('/projects/catch-em-all', {}, 'STAGING').indexable).toBe(false);
});
});

118
src/lib/seo.ts Normal file
View File

@@ -0,0 +1,118 @@
import { formatTitle, getTitle, type SiteEnvironment } from './title';
export const SITE_ORIGIN = 'https://stanrunge.dev';
type Article = {
title: string;
content: string;
updatedAt: Date | string;
};
type List = { name: string; isProgress: boolean; items: { name: string }[] };
type Race = { short: string; distance: number; discipline: string };
type PageContent = { blogArticle?: Article; list?: List; race?: Race };
type Seo = {
title: string;
description: string;
url: string;
indexable: boolean;
type: 'website' | 'article';
image: string;
imageAlt: string;
modifiedAt?: string;
};
const publicPages: Record<string, { title: string; description: string }> = {
'/': {
title: 'Projects, lists and writing',
description: 'Stan Runge’s projects, lists, writing and experience.'
},
'/projects': {
title: 'Projects',
description: 'Software projects by Stan Runge, including Catchemall, Animaldex and Derby Lens.'
},
'/blog': { title: 'Blog', description: 'Articles and notes by Stan Runge.' },
'/lists': { title: 'Lists', description: 'Lists collected and maintained by Stan Runge.' },
'/projects/catch-em-all': {
title: 'Catchemall',
description: 'Track which Pokémon you have caught across 53 games and 1,025 Pokémon.'
},
'/projects/animaldex': {
title: 'Animaldex',
description: 'Find and add neighborhood animals on a map.'
},
'/projects/derby': {
title: 'Derby Lens',
description: 'Estimate horse racing odds and compare them with market prices.'
},
'/projects/spotify-beatmap-finder': {
title: 'osu! map finder',
description: 'Find osu! beatmaps for Spotify songs, albums and playlists.'
}
};
function excerpt(markdown: string): string {
return markdown
.replace(/!\[[^\]]*\]\([^)]*\)/g, '')
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
.replace(/<[^>]*>/g, ' ')
.replace(/[`*_>#~|]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 160);
}
function date(value: Date | string): string | undefined {
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? undefined : parsed.toISOString();
}
export function getSeo(
pathname: string,
content: PageContent = {},
environment: SiteEnvironment = null
): Seo {
const path = pathname.replace(/\/$/, '') || '/';
const url = new URL(path, SITE_ORIGIN).href;
let page = publicPages[path];
let type: Seo['type'] = 'website';
let modifiedAt: string | undefined;
if (/^\/blog\/[^/]+$/.test(path) && content.blogArticle) {
const article = content.blogArticle;
page = {
title: article.title,
description: excerpt(article.content) || `Read ${article.title} by Stan Runge.`
};
type = 'article';
modifiedAt = date(article.updatedAt);
} else if (/^\/blog\/[^/]+\/edit$/.test(path) && content.blogArticle) {
page = { title: `Editing ${content.blogArticle.title}`, description: 'Edit a blog article.' };
} else if (/^\/lists\/[^/]+$/.test(path) && content.list) {
const list = content.list;
const names = list.items.slice(0, 3).map((item) => item.name);
page = {
title: list.name,
description: names.length
? `${list.name}: ${names.join(', ')}${list.items.length > names.length ? ', and more' : ''}.`
: `${list.name}, a ${list.isProgress ? 'progress ' : ''}list by Stan Runge.`
};
} else if (/^\/projects\/derby\/[^/]+$/.test(path) && content.race) {
const race = content.race;
page = {
title: `${race.short} · Derby Lens`,
description: `Estimate odds for the ${race.short}, a ${race.distance} m ${race.discipline} race.`
};
}
return {
title: page ? formatTitle(page.title, environment) : getTitle(path, environment),
description: page?.description ?? 'Stan Runge’s personal website.',
url,
indexable: !!page && !environment && !/^\/blog\/[^/]+\/edit$/.test(path),
type,
image: `${SITE_ORIGIN}/moon.jpg`,
imageAlt: 'Moon icon for Stan Runge’s website',
modifiedAt
};
}

View File

@@ -1,35 +1,88 @@
import { env } from '$env/dynamic/private';
import { building, dev } from '$app/environment';
import { betterAuth } from 'better-auth/minimal';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { genericOAuth } from 'better-auth/plugins';
import { APIError, createAuthMiddleware } from 'better-auth/api';
import { sveltekitCookies } from 'better-auth/svelte-kit';
import { getRequestEvent } from '$app/server';
import { db } from '$lib/server/db';
import * as schema from '$lib/server/db/schema';
import { error, type RequestEvent } from '@sveltejs/kit';
import { osuProfile } from './osu-profile';
export { isAdmin, requireAdmin, requireUser } from './authorization';
if (!building && (!env.BETTER_AUTH_SECRET || env.BETTER_AUTH_SECRET.length < 32)) {
throw new Error('Set BETTER_AUTH_SECRET to a random secret of at least 32 characters');
}
if (!building && !dev && !env.BETTER_AUTH_URL && !env.ORIGIN) {
throw new Error('Set BETTER_AUTH_URL to the public site origin');
}
export const authProviders = {
google: !!(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET),
osu: !!(env.OSU_CLIENT_ID && env.OSU_CLIENT_SECRET)
};
export const auth = betterAuth({
baseURL: env.ORIGIN ?? 'http://localhost:3000',
secret: env.BETTER_AUTH_SECRET ?? 'build-time-placeholder',
baseURL: env.BETTER_AUTH_URL || env.ORIGIN || 'http://localhost:5173',
secret: env.BETTER_AUTH_SECRET || 'build-only-placeholder-not-a-runtime-secret',
database: drizzleAdapter(db, { provider: 'pg', schema }),
emailAndPassword: { enabled: true },
socialProviders: {
github: {
clientId: env.GITHUB_CLIENT_ID,
clientSecret: env.GITHUB_CLIENT_SECRET
emailAndPassword: { enabled: true, minPasswordLength: 12, maxPasswordLength: 128 },
user: {
additionalFields: {
role: { type: 'string', required: false, defaultValue: 'user', input: false }
}
},
account: {
accountLinking: {
enabled: true,
disableImplicitLinking: true,
allowDifferentEmails: true,
trustedProviders: ['osu']
}
},
rateLimit: { enabled: true },
socialProviders: authProviders.google
? {
google: { clientId: env.GOOGLE_CLIENT_ID!, clientSecret: env.GOOGLE_CLIENT_SECRET! }
}
: {},
hooks: {
before: createAuthMiddleware(async (ctx) => {
// osu! has no email scope. Reserve its internal identifiers against credential signup.
if (
ctx.path === '/sign-up/email' &&
typeof ctx.body?.email === 'string' &&
ctx.body.email.toLowerCase().endsWith('.invalid')
) {
throw new APIError('BAD_REQUEST', { message: 'Please use a deliverable email address' });
}
})
},
plugins: [
sveltekitCookies(getRequestEvent) // make sure this is the last plugin in the array
genericOAuth({
config: authProviders.osu
? [
{
providerId: 'osu',
clientId: env.OSU_CLIENT_ID!,
clientSecret: env.OSU_CLIENT_SECRET!,
authorizationUrl: 'https://osu.ppy.sh/oauth/authorize',
tokenUrl: 'https://osu.ppy.sh/oauth/token',
scopes: ['identify'],
getUserInfo: async (tokens) => {
const response = await fetch('https://osu.ppy.sh/api/v2/me', {
headers: {
Authorization: `Bearer ${tokens.accessToken}`,
Accept: 'application/json'
},
signal: AbortSignal.timeout(10000)
});
if (!response.ok) throw new Error('Could not load osu! profile');
return osuProfile(await response.json());
}
}
]
: []
}),
sveltekitCookies(getRequestEvent)
]
});
export function isAdmin(event: RequestEvent): boolean {
const token = event.cookies.get('admin_token');
return !!token && token === env.ADMIN_SECRET;
}
export function requireAdmin(event: RequestEvent): void {
if (!isAdmin(event)) {
error(401, { message: 'Unauthorized' });
}
}

View File

@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { isAdmin, requireAdmin, requireUser } from './authorization';
function event(role?: string) {
return { locals: { user: role ? { id: 'alice', role } : null, session: null } } as {
locals: App.Locals;
};
}
describe('central authorization', () => {
it('rejects anonymous requests even with a legacy admin cookie', () => {
const anonymous = { ...event(), cookies: { get: () => 'old-admin-secret' } };
expect(isAdmin(anonymous)).toBe(false);
expect(() => requireAdmin(anonymous)).toThrow(expect.objectContaining({ status: 401 }));
});
it('allows normal users to own data but not administer the site', () => {
expect(requireUser(event('user')).id).toBe('alice');
expect(() => requireAdmin(event('user'))).toThrow(expect.objectContaining({ status: 403 }));
});
it('allows only the explicit admin role', () => {
expect(requireAdmin(event('admin')).id).toBe('alice');
expect(isAdmin(event('superadmin'))).toBe(false);
});
});

View File

@@ -0,0 +1,18 @@
import { error } from '@sveltejs/kit';
type AuthEvent = { locals: App.Locals };
export function requireUser(event: AuthEvent) {
if (!event.locals.user) error(401, 'Log in to continue');
return event.locals.user;
}
export function isAdmin(event: AuthEvent): boolean {
return event.locals.user?.role === 'admin';
}
export function requireAdmin(event: AuthEvent) {
const user = requireUser(event);
if (!isAdmin(event)) error(403, 'Administrator access required');
return user;
}

View File

@@ -0,0 +1,35 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
const catalog = JSON.parse(readFileSync('data/pokemon-catalog.json', 'utf8')) as {
pokemon: { number: number; name: string; hp: number; type1: string }[];
games: { sourceId: number; name: string }[];
entries: [number, number, string][];
};
describe('Pokémon catalog snapshot', () => {
it('contains the national species catalog and the latest source versions', () => {
expect(catalog.pokemon).toHaveLength(1025);
expect(catalog.games.length).toBeGreaterThanOrEqual(53);
expect(catalog.pokemon.find((p) => p.number === 25)).toMatchObject({
name: 'Pikachu',
type1: 'Electric',
hp: 35
});
expect(catalog.games.some((g) => g.name.includes('Z-A'))).toBe(true);
});
it('has unique species, games, and valid entry references', () => {
const pokemon = new Set(catalog.pokemon.map((p) => p.number));
const games = new Set(catalog.games.map((g) => g.sourceId));
expect(pokemon.size).toBe(catalog.pokemon.length);
expect(games.size).toBe(catalog.games.length);
expect(new Set(catalog.entries.map(([p, g]) => `${p}/${g}`)).size).toBe(catalog.entries.length);
expect(
catalog.entries.every(
([p, g, a]) => pokemon.has(p) && games.has(g) && ['catchable', 'unknown'].includes(a)
)
).toBe(true);
});
it('does not invent exclusive availability from missing encounters', () => {
expect(catalog.entries.some(([, , a]) => a === 'catchable')).toBe(true);
expect(catalog.entries.some(([, , a]) => a === 'unknown')).toBe(true);
});
});

View File

@@ -2,6 +2,27 @@ import { defineRelations } from 'drizzle-orm';
import * as schema from './schema';
export const relations = defineRelations(schema, (r) => ({
// ── Auth ──────────────────────────────────────
user: {
sessions: r.many.session(),
accounts: r.many.account(),
entries: r.many.userEntries(),
games: r.many.userGames(),
listCompletions: r.many.listItemCompletions()
},
session: {
user: r.one.user({
from: r.session.userId,
to: r.user.id
})
},
account: {
user: r.one.user({
from: r.account.userId,
to: r.user.id
})
},
// ── Progress ──────────────────────────────────
tasks: {
tasksToTopics: r.many.tasksToTopics()
@@ -21,61 +42,41 @@ export const relations = defineRelations(schema, (r) => ({
},
// ── CatchEmAll ────────────────────────────────
game: {
entries: r.many.pokemonGameEntry()
games: {
entries: r.many.pokemonGameEntries()
},
pokemon: {
entry: r.many.pokemonGameEntry()
entry: r.many.pokemonGameEntries()
},
pokemonGameEntry: {
game: r.one.game({
from: r.pokemonGameEntry.gameId,
to: r.game.id
pokemonGameEntries: {
game: r.one.games({
from: r.pokemonGameEntries.gameId,
to: r.games.id
}),
pokemon: r.one.pokemon({
from: r.pokemonGameEntry.pokemonId,
from: r.pokemonGameEntries.pokemonId,
to: r.pokemon.id
}),
userEntries: r.many.userEntry()
userEntries: r.many.userEntries()
},
userEntry: {
pokemonGameEntry: r.one.pokemonGameEntry({
from: r.userEntry.pokemonGameEntryId,
to: r.pokemonGameEntry.id
userEntries: {
pokemonGameEntry: r.one.pokemonGameEntries({
from: r.userEntries.pokemonGameEntryId,
to: r.pokemonGameEntries.id
}),
user: r.one.user({
from: r.userEntry.userId,
from: r.userEntries.userId,
to: r.user.id
})
},
userGame: {
userGames: {
user: r.one.user({
from: r.userGame.userId,
from: r.userGames.userId,
to: r.user.id
}),
game: r.one.game({
from: r.userGame.gameId,
to: r.game.id
})
},
// ── Auth ──────────────────────────────────────
user: {
sessions: r.many.session(),
accounts: r.many.account(),
entries: r.many.userEntry(),
games: r.many.userGame()
},
session: {
user: r.one.user({
from: r.session.userId,
to: r.user.id
})
},
account: {
user: r.one.user({
from: r.account.userId,
to: r.user.id
game: r.one.games({
from: r.userGames.gameId,
to: r.games.id
})
},
@@ -95,5 +96,27 @@ export const relations = defineRelations(schema, (r) => ({
from: r.photos.sightingId,
to: r.sightings.id
})
},
// ── Lists ─────────────────────────────────────
lists: {
items: r.many.listItems()
},
listItems: {
list: r.one.lists({
from: r.listItems.listId,
to: r.lists.id
}),
completions: r.many.listItemCompletions()
},
listItemCompletions: {
item: r.one.listItems({
from: r.listItemCompletions.itemId,
to: r.listItems.id
}),
user: r.one.user({
from: r.listItemCompletions.userId,
to: r.user.id
})
}
}));

View File

@@ -1,234 +0,0 @@
import {
doublePrecision,
index,
integer,
pgEnum,
pgTable,
real,
serial,
text,
timestamp,
unique,
uuid,
varchar
} from 'drizzle-orm/pg-core';
import { user } from './auth.schema';
import { availabilityValues } from '../../availability';
export const tasks = pgTable('tasks', {
id: serial('id').primaryKey(),
name: varchar('name', { length: 256 }),
currentPoints: integer('current_points').default(0),
totalPoints: integer('total_points').default(1),
notes: varchar('notes', { length: 8192 }),
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow()
});
export const topics = pgTable('topics', {
id: serial('id').primaryKey(),
name: varchar('name', { length: 256 }),
emoji: varchar('emoji', { length: 256 }),
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow()
});
export const tasksToTopics = pgTable('tasks_to_topics', {
taskId: integer('task_id')
.notNull()
.references(() => tasks.id),
topicId: integer('topic_id')
.notNull()
.references(() => topics.id)
});
export const universities = pgTable('universities', {
id: serial('id').primaryKey(),
name: varchar('name', { length: 256 }),
progress: integer('progress').default(0),
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow()
});
export const certificates = pgTable('certificates', {
id: serial('id').primaryKey(),
name: varchar('name', { length: 256 }),
progress: integer('progress').default(0),
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow()
});
export const publications = pgTable('publications', {
id: serial('id').primaryKey(),
name: varchar('name', { length: 256 }),
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow()
});
// Legacy ^
// New
export const blogArticleStatus = pgEnum('blog_article_status', ['draft', 'published']);
export const blogArticles = pgTable(
'blog_articles',
{
id: serial('id').primaryKey(),
title: varchar('title', { length: 256 }).notNull(),
slug: varchar('slug', { length: 256 }).notNull(),
content: text('content').default(''),
status: blogArticleStatus('status').notNull().default('draft'),
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow()
},
(table) => [unique().on(table.slug)]
);
// CatchEmAll
export const availability = pgEnum('availability', availabilityValues);
export const pokemon = pgTable('pokemon', {
id: serial('id').primaryKey(),
number: integer('number').notNull(),
generation: integer('generation').notNull().default(1),
name: varchar('name', { length: 256 }).notNull(),
type1: varchar('type1', { length: 256 }).notNull(),
type2: varchar('type2', { length: 256 }),
hp: integer('hp').notNull().default(0),
attack: integer('attack').notNull().default(0),
defense: integer('defense').notNull().default(0),
specialAttack: integer('special_attack').notNull().default(0),
specialDefense: integer('special_defense').notNull().default(0),
speed: integer('speed').notNull().default(0)
});
export const game = pgTable('game', {
id: serial('id').primaryKey(),
name: varchar('name', { length: 256 }).notNull(),
generation: integer('generation').notNull().default(1)
});
export const pokemonGameEntry = pgTable(
'pokemon_game_entry',
{
id: serial('id').primaryKey(),
pokemonId: integer('pokemon_id')
.notNull()
.references(() => pokemon.id, { onDelete: 'cascade' }),
gameId: integer('game_id')
.notNull()
.references(() => game.id, { onDelete: 'cascade' }),
availability: availability('availability').notNull().default('catchable'),
// optional extra detail for the tooltip, e.g. "trade with a Blue version owner"
note: varchar('note', { length: 256 })
},
(table) => [unique().on(table.pokemonId, table.gameId), index().on(table.gameId)]
);
export const userEntry = pgTable(
'user_entry',
{
id: serial('id').primaryKey(),
pokemonGameEntryId: integer('pokemon_game_entry_id')
.notNull()
.references(() => pokemonGameEntry.id, { onDelete: 'cascade' }),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' })
},
(table) => [unique().on(table.pokemonGameEntryId, table.userId), index().on(table.userId)]
);
// which games a user has chosen to track ("rules")
export const userGame = pgTable(
'user_game',
{
id: serial('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
gameId: integer('game_id')
.notNull()
.references(() => game.id, { onDelete: 'cascade' })
},
(table) => [unique().on(table.userId, table.gameId), index().on(table.userId)]
);
// Animaldex
export const animals = pgTable('animals', {
id: uuid('id').primaryKey().defaultRandom(),
species: text('species').notNull(),
breed: text('breed'),
animalName: text('animal_name'),
description: text('description'),
// AI detection stored on the animal (from the first/primary photo)
aiBreedSuggestion: text('ai_breed_suggestion'),
aiBreedConfidence: real('ai_breed_confidence'),
// Moderation — the animal entity is moderated once.
// Accepted = visible on map (as long as it has accepted sightings too).
// Denied animals are hidden but not deleted.
submittedAt: timestamp('submitted_at', { withTimezone: true }).notNull().defaultNow(),
acceptedAt: timestamp('accepted_at', { withTimezone: true }),
deniedAt: timestamp('denied_at', { withTimezone: true })
});
export const sightings = pgTable('sightings', {
id: uuid('id').primaryKey().defaultRandom(),
animalId: uuid('animal_id')
.notNull()
.references(() => animals.id, { onDelete: 'cascade' }),
reporterName: text('reporter_name'),
seenAt: timestamp('seen_at', { withTimezone: true }).notNull(),
lat: doublePrecision('lat').notNull(),
lng: doublePrecision('lng').notNull(),
submittedAt: timestamp('submitted_at', { withTimezone: true }).notNull().defaultNow(),
acceptedAt: timestamp('accepted_at', { withTimezone: true }),
deniedAt: timestamp('denied_at', { withTimezone: true })
});
export const photos = pgTable('photos', {
id: uuid('id').primaryKey().defaultRandom(),
sightingId: uuid('sighting_id')
.notNull()
.references(() => sightings.id, { onDelete: 'cascade' }),
r2Key: text('r2_key').notNull(),
url: text('url').notNull(),
sortOrder: integer('sort_order').notNull().default(0),
uploadedAt: timestamp('uploaded_at', { withTimezone: true }).notNull().defaultNow()
});
export type Pokemon = typeof pokemon.$inferSelect;
export type Game = typeof game.$inferSelect;
export type PokemonGameEntry = typeof pokemonGameEntry.$inferSelect;
export type UserEntry = typeof userEntry.$inferSelect;
export type UserGame = typeof userGame.$inferSelect;
export type Availability = (typeof availability.enumValues)[number];
export type Animal = typeof animals.$inferSelect;
export type NewAnimal = typeof animals.$inferInsert;
export type Sighting = typeof sightings.$inferSelect;
export type NewSighting = typeof sightings.$inferInsert;
export type Photo = typeof photos.$inferSelect;
// ── Helpers ───────────────────────────────────────────────────────────────────
export type ModerationStatus = 'pending' | 'accepted' | 'denied';
export function getModerationStatus(
row: Pick<Animal | Sighting, 'acceptedAt' | 'deniedAt'>
): ModerationStatus {
if (row.deniedAt) return 'denied';
if (row.acceptedAt) return 'accepted';
return 'pending';
}
export * from './auth.schema';

View File

@@ -0,0 +1,69 @@
import { doublePrecision, integer, pgTable, text } from 'drizzle-orm/pg-core';
import { id, timestamps, timestamptz } from './helpers';
export const animals = pgTable('animals', {
id: id(),
species: text('species').notNull(),
breed: text('breed'),
animalName: text('animal_name'),
description: text('description'),
// AI detection stored on the animal (from the first/primary photo)
aiBreedSuggestion: text('ai_breed_suggestion'),
aiBreedConfidence: doublePrecision('ai_breed_confidence'),
// Moderation — the animal entity is moderated once.
// Accepted = visible on map (as long as it has accepted sightings too).
// Denied animals are hidden but not deleted.
submittedAt: timestamptz('submitted_at').notNull().defaultNow(),
acceptedAt: timestamptz('accepted_at'),
deniedAt: timestamptz('denied_at'),
...timestamps
});
export const sightings = pgTable('sightings', {
id: id(),
animalId: integer('animal_id')
.notNull()
.references(() => animals.id, { onDelete: 'cascade' }),
reporterName: text('reporter_name'),
seenAt: timestamptz('seen_at').notNull(),
lat: doublePrecision('lat').notNull(),
lng: doublePrecision('lng').notNull(),
submittedAt: timestamptz('submitted_at').notNull().defaultNow(),
acceptedAt: timestamptz('accepted_at'),
deniedAt: timestamptz('denied_at'),
...timestamps
});
export const photos = pgTable('photos', {
id: id(),
sightingId: integer('sighting_id')
.notNull()
.references(() => sightings.id, { onDelete: 'cascade' }),
r2Key: text('r2_key').notNull(),
url: text('url').notNull(),
sortOrder: integer('sort_order').notNull().default(0),
uploadedAt: timestamptz('uploaded_at').notNull().defaultNow(),
...timestamps
});
export type Animal = typeof animals.$inferSelect;
export type NewAnimal = typeof animals.$inferInsert;
export type Sighting = typeof sightings.$inferSelect;
export type NewSighting = typeof sightings.$inferInsert;
export type Photo = typeof photos.$inferSelect;
export type ModerationStatus = 'pending' | 'accepted' | 'denied';
export function getModerationStatus(
row: Pick<Animal | Sighting, 'acceptedAt' | 'deniedAt'>
): ModerationStatus {
if (row.deniedAt) return 'denied';
if (row.acceptedAt) return 'accepted';
return 'pending';
}

View File

@@ -1,13 +1,14 @@
import { pgTable, text, timestamp, boolean, index } from 'drizzle-orm/pg-core';
import { pgTable, text, timestamp, boolean, index, uniqueIndex } from 'drizzle-orm/pg-core';
export const user = pgTable('user', {
id: text('id').primaryKey(),
name: text('name').notNull(),
role: text('role').notNull().default('user'),
email: text('email').notNull().unique(),
emailVerified: boolean('email_verified').default(false).notNull(),
image: text('image'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull()
@@ -17,10 +18,10 @@ export const session = pgTable(
'session',
{
id: text('id').primaryKey(),
expiresAt: timestamp('expires_at').notNull(),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
token: text('token').notNull().unique(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
ipAddress: text('ip_address'),
@@ -37,6 +38,7 @@ export const account = pgTable(
{
id: text('id').primaryKey(),
accountId: text('account_id').notNull(),
issuer: text('issuer').notNull(),
providerId: text('provider_id').notNull(),
userId: text('user_id')
.notNull()
@@ -44,16 +46,19 @@ export const account = pgTable(
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
idToken: text('id_token'),
accessTokenExpiresAt: timestamp('access_token_expires_at'),
refreshTokenExpiresAt: timestamp('refresh_token_expires_at'),
accessTokenExpiresAt: timestamp('access_token_expires_at', { withTimezone: true }),
refreshTokenExpiresAt: timestamp('refresh_token_expires_at', { withTimezone: true }),
scope: text('scope'),
password: text('password'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull()
},
(table) => [index('account_userId_idx').on(table.userId)]
(table) => [
index('account_userId_idx').on(table.userId),
uniqueIndex('account_issuer_account_id_unique').on(table.issuer, table.accountId)
]
);
export const verification = pgTable(
@@ -62,9 +67,9 @@ export const verification = pgTable(
id: text('id').primaryKey(),
identifier: text('identifier').notNull(),
value: text('value').notNull(),
expiresAt: timestamp('expires_at').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull()

View File

@@ -0,0 +1,22 @@
import { pgEnum, pgTable, text, unique } from 'drizzle-orm/pg-core';
import { id, timestamps } from './helpers';
export const blogArticleStatus = pgEnum('blog_article_status', ['draft', 'published']);
export const blogArticles = pgTable(
'blog_articles',
{
id: id(),
title: text('title').notNull(),
slug: text('slug').notNull(),
content: text('content').notNull().default(''),
tags: text('tags').array().notNull().default([]),
status: blogArticleStatus('status').notNull().default('draft'),
...timestamps
},
(table) => [unique().on(table.slug)]
);
export type BlogArticle = typeof blogArticles.$inferSelect;
export type NewBlogArticle = typeof blogArticles.$inferInsert;
export type BlogArticleStatus = (typeof blogArticleStatus.enumValues)[number];

View File

@@ -0,0 +1,96 @@
import { index, integer, pgEnum, pgTable, text, unique } from 'drizzle-orm/pg-core';
import { availabilityValues } from '../../../availability';
import { user } from './auth';
import { id, timestamps } from './helpers';
export const availability = pgEnum('availability', availabilityValues);
export const pokemon = pgTable('pokemon', {
id: id(),
number: integer('number').notNull().unique(),
generation: integer('generation').notNull().default(1),
name: text('name').notNull(),
type1: text('type1').notNull(),
type2: text('type2'),
hp: integer('hp').notNull().default(0),
attack: integer('attack').notNull().default(0),
defense: integer('defense').notNull().default(0),
specialAttack: integer('special_attack').notNull().default(0),
specialDefense: integer('special_defense').notNull().default(0),
speed: integer('speed').notNull().default(0),
...timestamps
});
export const games = pgTable('games', {
id: id(),
sourceId: integer('source_id').unique(),
name: text('name').notNull(),
generation: integer('generation').notNull().default(1),
...timestamps
});
export const pokemonGameEntries = pgTable(
'pokemon_game_entries',
{
id: id(),
pokemonId: integer('pokemon_id')
.notNull()
.references(() => pokemon.id, { onDelete: 'cascade' }),
gameId: integer('game_id')
.notNull()
.references(() => games.id, { onDelete: 'cascade' }),
availability: availability('availability').notNull().default('catchable'),
// optional extra detail for the tooltip, e.g. "trade with a Blue version owner"
note: text('note'),
...timestamps
},
(table) => [unique().on(table.pokemonId, table.gameId), index().on(table.gameId)]
);
export const userEntries = pgTable(
'user_entries',
{
id: id(),
pokemonGameEntryId: integer('pokemon_game_entry_id')
.notNull()
.references(() => pokemonGameEntries.id, { onDelete: 'cascade' }),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
...timestamps
},
(table) => [unique().on(table.pokemonGameEntryId, table.userId), index().on(table.userId)]
);
// which games a user has chosen to track ("rules")
export const userGames = pgTable(
'user_games',
{
id: id(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
gameId: integer('game_id')
.notNull()
.references(() => games.id, { onDelete: 'cascade' }),
...timestamps
},
(table) => [unique().on(table.userId, table.gameId), index().on(table.userId)]
);
// A row distinguishes an explicit empty game selection from a new account.
export const catchEmAllSettings = pgTable('catch_em_all_settings', {
id: id(),
userId: text('user_id')
.notNull()
.unique()
.references(() => user.id, { onDelete: 'cascade' }),
...timestamps
});
export type Pokemon = typeof pokemon.$inferSelect;
export type Game = typeof games.$inferSelect;
export type PokemonGameEntry = typeof pokemonGameEntries.$inferSelect;
export type UserEntry = typeof userEntries.$inferSelect;
export type UserGame = typeof userGames.$inferSelect;
export type Availability = (typeof availability.enumValues)[number];

View File

@@ -0,0 +1,17 @@
import { serial, timestamp } from 'drizzle-orm/pg-core';
// Shared column patterns so every table is built the same way.
// Timestamps are always `timestamptz` — they represent a moment in time, not a
// wall-clock reading.
export const id = () => serial('id').primaryKey();
export const timestamptz = (name: string) => timestamp(name, { withTimezone: true });
export const timestamps = {
createdAt: timestamptz('created_at').notNull().defaultNow(),
updatedAt: timestamptz('updated_at')
.notNull()
.defaultNow()
.$onUpdate(() => new Date())
};

View File

@@ -0,0 +1,8 @@
// One file per domain. Everything is re-exported here so the rest of the app
// (and drizzle-kit / relations) only ever imports `$lib/server/db/schema`.
export * from './auth';
export * from './blog';
export * from './progress';
export * from './catch-em-all';
export * from './animaldex';
export * from './lists';

View File

@@ -0,0 +1,46 @@
import { boolean, index, integer, pgTable, text, unique } from 'drizzle-orm/pg-core';
import { id, timestamps } from './helpers';
import { user } from './auth';
export const lists = pgTable('lists', {
id: id(),
name: text('name').notNull(),
slug: text('slug').notNull().unique(),
isProgress: boolean('is_progress').notNull().default(false),
...timestamps
});
export const listItems = pgTable(
'list_items',
{
id: id(),
listId: integer('list_id')
.notNull()
.references(() => lists.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
pictureUrl: text('picture_url'),
description: text('description'),
...timestamps
},
(table) => [index().on(table.listId)]
);
export const listItemCompletions = pgTable(
'list_item_completions',
{
id: id(),
itemId: integer('item_id')
.notNull()
.references(() => listItems.id, { onDelete: 'cascade' }),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
...timestamps
},
(table) => [unique().on(table.itemId, table.userId), index().on(table.userId)]
);
export type List = typeof lists.$inferSelect;
export type NewList = typeof lists.$inferInsert;
export type ListItem = typeof listItems.$inferSelect;
export type NewListItem = typeof listItems.$inferInsert;

View File

@@ -0,0 +1,38 @@
import { integer, pgTable, text, unique } from 'drizzle-orm/pg-core';
import { id, timestamps } from './helpers';
export const tasks = pgTable('tasks', {
id: id(),
name: text('name').notNull(),
currentPoints: integer('current_points').notNull().default(0),
totalPoints: integer('total_points').notNull().default(1),
notes: text('notes'),
...timestamps
});
export const topics = pgTable('topics', {
id: id(),
name: text('name').notNull(),
emoji: text('emoji'),
...timestamps
});
export const tasksToTopics = pgTable(
'tasks_to_topics',
{
id: id(),
taskId: integer('task_id')
.notNull()
.references(() => tasks.id, { onDelete: 'cascade' }),
topicId: integer('topic_id')
.notNull()
.references(() => topics.id, { onDelete: 'cascade' }),
...timestamps
},
(table) => [unique().on(table.taskId, table.topicId)]
);
export type Task = typeof tasks.$inferSelect;
export type NewTask = typeof tasks.$inferInsert;
export type Topic = typeof topics.$inferSelect;
export type NewTopic = typeof topics.$inferInsert;

View File

@@ -0,0 +1,119 @@
export class ApiError extends Error {
constructor(
public provider: string,
public status: number,
message: string
) {
super(message);
}
}
export function publicError(error: unknown): string {
return error instanceof ApiError
? error.message
: 'Search could not be completed. Please try again.';
}
export function logApiError(error: unknown) {
console.error(
'Map finder request failed',
error instanceof ApiError
? { provider: error.provider, status: error.status }
: { type: 'unexpected' }
);
}
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
const cooldowns = new Map<string, number>();
export async function requestJson(
fetchFn: typeof fetch,
provider: string,
url: string,
init: RequestInit = {},
wait = sleep
): Promise<unknown> {
for (let attempt = 0; attempt < 3; attempt++) {
if ((cooldowns.get(provider) ?? 0) > Date.now())
throw new ApiError(provider, 429, `${provider} is rate-limited. Please try again later.`);
let response: Response;
try {
response = await fetchFn(url, { ...init, signal: AbortSignal.timeout(12000) });
} catch {
if (attempt < 2) {
await wait(300 * 2 ** attempt + Math.random() * 150);
continue;
}
throw new ApiError(provider, 503, `${provider} is unavailable. Please try again later.`);
}
if (response.ok) {
try {
return await response.json();
} catch {
throw new ApiError(
provider,
502,
`${provider} returned an invalid response. Please try again.`
);
}
}
if (response.status === 429 || response.status >= 500) {
const retry = response.headers.get('Retry-After');
const delay = retry
? /^\d+(\.\d+)?$/.test(retry)
? Number(retry) * 1000
: Math.max(0, Date.parse(retry) - Date.now())
: 300 * 2 ** attempt;
const boundedDelay = Number.isFinite(delay) ? delay : 1000;
if (response.status === 429) cooldowns.set(provider, Date.now() + boundedDelay);
if (attempt < 2 && boundedDelay <= 4000) {
await wait(boundedDelay + Math.random() * 150);
continue;
}
}
throw new ApiError(
provider,
response.status,
response.status === 429
? `${provider} is rate-limited. Please try again later.`
: `${provider} request failed. Please try again later.`
);
}
throw new ApiError(provider, 503, `${provider} is unavailable.`);
}
export function createTokenCache() {
let token: { value: string; expires: number } | undefined;
let pending: Promise<string> | undefined;
return async (refresh: () => Promise<unknown>): Promise<string> => {
if (token && token.expires > Date.now() + 30000) return token.value;
if (!pending)
pending = (async () => {
const result = (await refresh()) as { access_token?: unknown; expires_in?: unknown };
if (
typeof result?.access_token !== 'string' ||
typeof result.expires_in !== 'number' ||
result.expires_in <= 0
)
throw new ApiError('OAuth', 502, 'API authentication is temporarily unavailable.');
token = { value: result.access_token, expires: Date.now() + result.expires_in * 1000 };
return token.value;
})().finally(() => {
pending = undefined;
});
return pending;
};
}
export async function mapConcurrent<T, R>(
items: T[],
limit: number,
work: (item: T) => Promise<R>
): Promise<R[]> {
if (!Number.isInteger(limit) || limit < 1) throw new Error('Concurrency must be positive.');
const results: R[] = new Array(items.length);
let cursor = 0;
await Promise.all(
Array.from({ length: Math.min(limit, items.length) }, async () => {
while (cursor < items.length) {
const index = cursor++;
results[index] = await work(items[index]);
}
})
);
return results;
}

View File

@@ -0,0 +1,28 @@
export const RATE_WINDOW_MS = 10 * 60_000;
export const RATE_BUDGET = 12;
export function createRateLimiter() {
let activeSearches = 0;
const records = new Map<string, { expires: number; used: number; active: boolean }>();
return (key: string, cost: number, now = Date.now()) => {
// Opportunistic cleanup on every action; no timers keep the server alive.
for (const [id, entry] of records)
if (entry.expires <= now && !entry.active) records.delete(id);
let entry = records.get(key);
if (!entry) {
if (records.size >= 10000) return null;
entry = { expires: now + RATE_WINDOW_MS, used: 0, active: false };
records.set(key, entry);
}
if (activeSearches >= 6 || entry.active || entry.used + cost > RATE_BUDGET) return null;
entry.used += cost;
entry.active = true;
activeSearches++;
return () => {
if (entry.active) {
entry.active = false;
activeSearches--;
}
};
};
}
export const acquireSearch = createRateLimiter();

View File

@@ -0,0 +1,167 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
vi.mock('$env/dynamic/private', () => ({
env: { SPOTIFY_CLIENT_ID: 'test', SPOTIFY_CLIENT_SECRET: 'test' }
}));
import { createTokenCache, mapConcurrent, requestJson } from './finder-http';
import { createRateLimiter, RATE_WINDOW_MS } from './finder-limit';
import { getSpotifyMetadata, playlistTracks, MAX_PLAYLIST_TRACKS } from './spotify';
const id = (n: number) => String(n).padStart(22, '0');
const track = (n: number) => ({
type: 'track',
id: id(n),
name: `Song ${n}`,
artists: [{ name: 'Artist' }],
album: { name: 'Album', images: [] }
});
afterEach(() => vi.useRealTimers());
describe('request controls', () => {
it('reuses tokens, coalesces refreshes and refreshes near expiry', async () => {
vi.useFakeTimers();
const cache = createTokenCache();
const refresh = vi.fn(async () => ({ access_token: 'token', expires_in: 60 }));
expect(await Promise.all([cache(refresh), cache(refresh)])).toEqual(['token', 'token']);
await cache(refresh);
expect(refresh).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(31000);
await cache(refresh);
expect(refresh).toHaveBeenCalledTimes(2);
});
it('bounds concurrency and preserves order', async () => {
let active = 0,
peak = 0;
const result = await mapConcurrent([3, 2, 1, 0], 2, async (n) => {
active++;
peak = Math.max(peak, active);
await new Promise((r) => setTimeout(r, n));
active--;
return n * 2;
});
expect(peak).toBe(2);
expect(result).toEqual([6, 4, 2, 0]);
});
it('retries 429 after Retry-After', async () => {
vi.useFakeTimers();
const fetchFn = vi
.fn()
.mockResolvedValueOnce(new Response('', { status: 429, headers: { 'Retry-After': '1' } }))
.mockResolvedValueOnce(Response.json({ ok: true }));
const wait = vi.fn(async (ms: number) => {
vi.advanceTimersByTime(ms);
});
expect(await requestJson(fetchFn, 'retry-test', 'https://example.test', {}, wait)).toEqual({
ok: true
});
expect(wait.mock.calls[0][0]).toBeGreaterThanOrEqual(1000);
expect(fetchFn).toHaveBeenCalledTimes(2);
});
it('does not retry 404 or malformed JSON', async () => {
const fetchFn = vi.fn().mockResolvedValue(new Response('', { status: 404 }));
await expect(requestJson(fetchFn, 'test', 'https://example.test')).rejects.toMatchObject({
status: 404
});
expect(fetchFn).toHaveBeenCalledTimes(1);
await expect(
requestJson(
vi.fn().mockResolvedValue(new Response('invalid')),
'test',
'https://example.test'
)
).rejects.toMatchObject({ status: 502 });
});
it('limits server retries and respects long cooldowns without sleeping', async () => {
const fetchFn = vi.fn().mockImplementation(async () => new Response('', { status: 503 }));
await expect(
requestJson(fetchFn, 'server-test', 'https://example.test', {}, async () => {})
).rejects.toMatchObject({ status: 503 });
expect(fetchFn).toHaveBeenCalledTimes(3);
const throttled = vi
.fn()
.mockResolvedValue(new Response('', { status: 429, headers: { 'Retry-After': '60' } }));
await expect(
requestJson(throttled, 'long-retry', 'https://example.test')
).rejects.toMatchObject({ status: 429 });
expect(throttled).toHaveBeenCalledTimes(1);
});
it('limits weighted searches, concurrent requests and expires records', () => {
const acquire = createRateLimiter();
const release = acquire('ip', 6, 0)!;
expect(acquire('ip', 1, 1)).toBeNull();
release();
acquire('ip', 6, 2)!();
expect(acquire('ip', 1, 3)).toBeNull();
expect(acquire('ip', 6, RATE_WINDOW_MS + 1)).toBeTypeOf('function');
});
});
describe('playlists', () => {
it('skips unusable entries and deduplicates in order', () =>
expect(
playlistTracks([
{ track: track(2) },
{ track: null },
{ track: { ...track(3), is_local: true } },
{ track: { ...track(4), type: 'episode' } },
{ track: { ...track(5), is_playable: false } },
{ track: track(2) },
{ item: track(1) },
{ track: { id: id(7) } }
]).map((t) => t.id)
).toEqual([id(2), id(1)]));
it('paginates, deduplicates across pages and truncates', async () => {
const fetchFn = vi.fn(async (url: string | URL | Request) => {
const value = String(url);
if (value.includes('/api/token'))
return Response.json({ access_token: 'token', expires_in: 3600 });
if (value.includes('offset=2'))
return Response.json({
items: Array.from({ length: 55 }, (_, n) => ({ track: track(n + 1) })),
next: null,
total: 57
});
return Response.json({
name: 'Playlist',
owner: { display_name: 'Owner' },
tracks: {
items: [{ track: track(1) }, { track: null }],
next: 'https://evil.test',
total: 57
}
});
});
const result = await getSpotifyMetadata(fetchFn as typeof fetch, {
kind: 'playlist',
id: id(1)
});
expect(result.tracks).toHaveLength(MAX_PLAYLIST_TRACKS);
expect(result.tracks[0].id).toBe(id(1));
expect(result.tracks[1].id).toBe(id(2));
expect(result.truncated).toBe(true);
expect(fetchFn.mock.calls.every(([url]) => !String(url).includes('evil.test'))).toBe(true);
});
it('supports the newer items/item response', async () => {
const fetchFn = vi.fn(async () =>
Response.json({ name: 'New playlist', items: { items: [{ item: track(1) }], next: null } })
);
const result = await getSpotifyMetadata(fetchFn, { kind: 'playlist', id: id(1) });
expect(result.tracks).toHaveLength(1);
expect(result.truncated).toBe(false);
});
});
it('paginates album tracks and attaches shared album metadata', async () => {
const fetchFn = vi.fn(async (url: string | URL | Request) =>
String(url).includes('offset=1')
? Response.json({ items: [track(2)], next: null, total: 2 })
: Response.json({
name: 'Album name',
artists: [{ name: 'Artist' }],
images: [{ url: 'https://example.test/cover.jpg' }],
tracks: { items: [track(1)], next: 'next', total: 2 }
})
);
const result = await getSpotifyMetadata(fetchFn as typeof fetch, { kind: 'album', id: id(1) });
expect(result.collection).toMatchObject({ kind: 'album', name: 'Album name', owner: 'Artist' });
expect(result.tracks.map((t) => t.id)).toEqual([id(1), id(2)]);
expect(result.tracks[1].album.name).toBe('Album name');
expect(result.truncated).toBe(false);
expect(String(fetchFn.mock.calls[1][0])).toContain('/albums/');
});

View File

@@ -0,0 +1,20 @@
import { expect, it } from 'vitest';
import { osuProfile } from './osu-profile';
it('binds osu accounts to numeric provider IDs, never a claimed email or username', () => {
const profile = osuProfile({
id: 123,
username: 'Alice',
avatar_url: 'https://a.ppy.sh/123',
email: 'admin@example.com',
email_verified: true
});
expect(profile).toMatchObject({ id: '123', email: '123@osu.invalid', emailVerified: false });
expect(
osuProfile({ id: 123, username: 'Renamed', avatar_url: 'https://a.ppy.sh/123' }).email
).toBe(profile.email);
});
it('rejects malformed provider identities', () => {
expect(() =>
osuProfile({ id: 'admin', username: 'Alice', avatar_url: 'https://a.ppy.sh/123' })
).toThrow();
});

View File

@@ -0,0 +1,16 @@
import { z } from 'zod';
const profileSchema = z.object({
id: z.number().int().positive(),
username: z.string().min(1),
avatar_url: z.string().url()
});
export function osuProfile(value: unknown) {
const profile = profileSchema.parse(value);
return {
id: String(profile.id),
name: profile.username,
image: profile.avatar_url,
email: `${profile.id}@osu.invalid`,
emailVerified: false
};
}

View File

@@ -0,0 +1,37 @@
import { expect, it, vi } from 'vitest';
vi.mock('$env/dynamic/private', () => ({ env: { OSU_CLIENT_ID: '1', OSU_CLIENT_SECRET: 'test' } }));
import { getOsuMaps } from './osu';
it('falls back deliberately, merges IDs, reuses OAuth and caches matching results', async () => {
const fetchFn = vi.fn(async (url: string | URL | Request) => {
if (String(url).includes('/oauth/token'))
return Response.json({ access_token: 'token', expires_in: 3600 });
const query = new URL(String(url)).searchParams.get('q');
return Response.json({
beatmapsets: [
{
id: 1,
title: 'Unique Song',
artist: query === 'unique song' ? 'Artist' : 'Wrong Performer'
}
]
});
});
const track = {
id: '6PUzxtIHkv346yP89NzP9X',
name: 'Unique Song',
artists: [{ name: 'Artist' }],
album: { name: '', images: [] }
};
const results = await Promise.all([
getOsuMaps(fetchFn as typeof fetch, track),
getOsuMaps(fetchFn as typeof fetch, track)
]);
expect(results[0]).toHaveLength(1);
expect(results[0][0].score).toBe(100);
expect(results[1]).toEqual(results[0]);
await getOsuMaps(fetchFn as typeof fetch, track);
expect(fetchFn).toHaveBeenCalledTimes(3);
expect(fetchFn.mock.calls.filter(([url]) => String(url).includes('/oauth/token'))).toHaveLength(
1
);
});

90
src/lib/server/osu.ts Normal file
View File

@@ -0,0 +1,90 @@
import { env } from '$env/dynamic/private';
import type { OsuBeatmapset, ScoredBeatmapset, SpotifyTrack } from '$lib/map-finder/types';
import { cleanTitle, normalize, rankCandidates, MEDIUM_CONFIDENCE } from '$lib/map-finder/matching';
import { ApiError, createTokenCache, requestJson } from './finder-http';
const token = createTokenCache();
const cache = new Map<string, { expires: number; result: ScoredBeatmapset[] }>();
const pending = new Map<string, Promise<ScoredBeatmapset[]>>();
export const MATCH_CONCURRENCY = 3;
// Global permits also bound outbound work across simultaneous visitors.
let active = 0;
const queue: (() => void)[] = [];
async function limited<T>(work: () => Promise<T>): Promise<T> {
if (active >= MATCH_CONCURRENCY) await new Promise<void>((resolve) => queue.push(resolve));
else active++;
try {
return await work();
} finally {
const next = queue.shift();
if (next) next();
else active--;
}
}
async function search(fetchFn: typeof fetch, query: string): Promise<OsuBeatmapset[]> {
const access = await token(() => {
if (!env.OSU_CLIENT_ID || !env.OSU_CLIENT_SECRET)
throw new ApiError('osu!', 503, 'osu! is not configured on this server.');
return requestJson(fetchFn, 'osu!', 'https://osu.ppy.sh/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({
client_id: Number(env.OSU_CLIENT_ID),
client_secret: env.OSU_CLIENT_SECRET,
grant_type: 'client_credentials',
scope: 'public'
})
});
});
const response = (await requestJson(
fetchFn,
'osu!',
`https://osu.ppy.sh/api/v2/beatmapsets/search?${new URLSearchParams({ q: query, s: 'any' })}`,
{ headers: { Authorization: `Bearer ${access}`, Accept: 'application/json' } }
)) as { beatmapsets?: unknown[] };
if (!Array.isArray(response?.beatmapsets))
throw new ApiError('osu!', 502, 'osu! returned an invalid search response.');
return response.beatmapsets
.filter(
(item): item is OsuBeatmapset =>
!!item &&
typeof item === 'object' &&
Number.isInteger((item as OsuBeatmapset).id) &&
typeof (item as OsuBeatmapset).title === 'string' &&
typeof (item as OsuBeatmapset).artist === 'string'
)
.map((item) => ({
id: item.id,
title: item.title,
artist: item.artist,
title_unicode: typeof item.title_unicode === 'string' ? item.title_unicode : undefined,
artist_unicode: typeof item.artist_unicode === 'string' ? item.artist_unicode : undefined,
status: typeof item.status === 'string' ? item.status : undefined,
covers: { card: typeof item.covers?.card === 'string' ? item.covers.card : undefined },
beatmaps: Array.isArray(item.beatmaps) ? item.beatmaps.map(() => ({ id: 0 })) : undefined
}));
}
export async function getOsuMaps(
fetchFn: typeof fetch,
track: SpotifyTrack
): Promise<ScoredBeatmapset[]> {
const key = JSON.stringify([normalize(track.name), track.artists.map((a) => normalize(a.name))]);
for (const [key, entry] of cache) if (entry.expires <= Date.now()) cache.delete(key);
const hit = cache.get(key);
if (hit) return hit.result;
const existing = pending.get(key);
if (existing) return existing;
const work = limited(async () => {
const maps = await search(fetchFn, `${track.artists[0].name} ${track.name}`);
let ranked = rankCandidates(track, maps);
if (!ranked.some((m) => m.score >= MEDIUM_CONFIDENCE))
ranked = rankCandidates(track, [
...maps,
...(await search(fetchFn, cleanTitle(track.name) || track.name))
]);
if (cache.size >= 500) cache.delete(cache.keys().next().value!);
cache.set(key, { expires: Date.now() + 5 * 60_000, result: ranked });
return ranked;
}).finally(() => pending.delete(key));
pending.set(key, work);
return work;
}

View File

@@ -1,5 +1,4 @@
import { S3Client, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { env } from '$env/dynamic/private';
import { randomUUID } from 'crypto';
@@ -44,38 +43,41 @@ const EXT: Record<AllowedMimeType, string> = {
'image/heic': 'heic'
};
export interface PresignedUpload {
/** R2 object key — store this in the DB */
key: string;
/** One-time presigned PUT URL — browser PUTs file directly here */
uploadUrl: string;
/** Permanent public URL to store in photos table */
publicUrl: string;
}
/**
* Generate a presigned PUT URL for a single photo.
*
* Flow:
* 1. Client POSTs to /api/upload/presign → gets { key, uploadUrl, publicUrl }
* 2. Client PUTs file to uploadUrl (direct to R2, server never sees the bytes)
* 3. Client submits sighting with photo keys included
* 4. Server stores key + publicUrl in photos table
*/
export async function createPresignedUpload(mimeType: AllowedMimeType): Promise<PresignedUpload> {
const key = `sightings/${randomUUID()}.${EXT[mimeType]}`;
const uploadUrl = await getSignedUrl(
getClient(),
/** Upload through the site server so browsers do not need R2 CORS access. */
export async function uploadImage(
file: File,
prefix: 'sightings' | 'lists' = 'sightings'
): Promise<{ key: string; publicUrl: string }> {
if (!isAllowedMimeType(file.type)) throw new Error('Unsupported image type');
const mimeType = file.type;
const key = `${prefix}/${randomUUID()}.${EXT[mimeType]}`;
await getClient().send(
new PutObjectCommand({
Bucket: env.R2_BUCKET,
Key: key,
ContentType: mimeType
}),
{ expiresIn: 3600 }
ContentType: mimeType,
Body: new Uint8Array(await file.arrayBuffer())
})
);
return { key, publicUrl: `${env.R2_PUBLIC_URL!.replace(/\/$/, '')}/${key}` };
}
return { key, uploadUrl, publicUrl: `${env.R2_PUBLIC_URL}/${key}` };
export function listImageUrl(key: string): string | null {
if (
!/^lists\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.(jpg|png|webp)$/.test(
key
)
)
return null;
if (!env.R2_PUBLIC_URL) return null;
return `${env.R2_PUBLIC_URL.replace(/\/$/, '')}/${key}`;
}
export function listImageKey(url: string): string | null {
const base = env.R2_PUBLIC_URL?.replace(/\/$/, '');
if (!base || !url.startsWith(`${base}/`)) return null;
const key = url.slice(base.length + 1);
return listImageUrl(key) === url ? key : null;
}
/**

View File

@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import { slugify, validSlug } from './slugify';
describe('content slugs', () => {
it('turns titles into readable URL segments', () => {
expect(slugify('Hello, World!')).toBe('hello-world');
expect(slugify(' A / B ')).toBe('a-b');
expect(slugify('!!!', 'list')).toBe('list');
});
it('accepts only canonical editable slugs', () => {
expect(validSlug('my-list-2026')).toBe(true);
for (const value of ['My-List', 'two words', '-leading', 'trailing-', 'double--dash', '']) {
expect(validSlug(value)).toBe(false);
}
});
});

View File

@@ -1,14 +1,14 @@
import { db } from '$lib/server/db';
import { blogArticles } from '$lib/server/db/schema';
import { blogArticles, lists } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
export function slugify(title: string): string {
export function slugify(title: string, fallback = 'article'): string {
return (
title
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'article'
.replace(/^-+|-+$/g, '') || fallback
);
}
@@ -27,3 +27,17 @@ export async function uniqueSlug(title: string): Promise<string> {
return slug;
}
export async function uniqueListSlug(name: string): Promise<string> {
const base = slugify(name, 'list');
let slug = base;
let suffix = 2;
while ((await db.select({ id: lists.id }).from(lists).where(eq(lists.slug, slug))).length > 0) {
slug = `${base}-${suffix++}`;
}
return slug;
}
export function validSlug(slug: string): boolean {
return slug.length <= 120 && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug);
}

188
src/lib/server/spotify.ts Normal file
View File

@@ -0,0 +1,188 @@
import { env } from '$env/dynamic/private';
import type { SpotifyInput } from '$lib/map-finder/input';
import type { SpotifyTrack, SpotifyCollection } from '$lib/map-finder/types';
import { ApiError, createTokenCache, requestJson } from './finder-http';
export const MAX_PLAYLIST_TRACKS = 50;
export const MAX_PLAYLIST_PAGES = 10;
const token = createTokenCache();
type RecordValue = Record<string, unknown>;
function record(value: unknown): RecordValue {
return value && typeof value === 'object' ? (value as RecordValue) : {};
}
export function usableTrack(value: unknown): SpotifyTrack | null {
const track = record(value),
album = record(track.album);
if (
track.type !== 'track' ||
track.is_local ||
track.is_playable === false ||
typeof track.id !== 'string' ||
!/^[a-zA-Z0-9]{22}$/.test(track.id) ||
typeof track.name !== 'string' ||
!track.name.trim()
)
return null;
const artists = Array.isArray(track.artists)
? track.artists
.map(record)
.filter((a) => typeof a.name === 'string' && a.name.trim())
.map((a) => ({ name: a.name as string }))
: [];
if (!artists.length) return null;
const images = Array.isArray(album.images)
? album.images
.map(record)
.filter((i) => typeof i.url === 'string' && i.url.startsWith('https://'))
.map((i) => ({ url: i.url as string }))
: [];
return {
id: track.id,
name: track.name,
artists,
album: { name: typeof album.name === 'string' ? album.name : '', images }
};
}
export function playlistTracks(items: unknown[], seen = new Set<string>()): SpotifyTrack[] {
const result: SpotifyTrack[] = [];
for (const entry of items) {
const item = record(entry);
if (item.is_local) continue;
const track = usableTrack(item.track ?? item.item);
if (track && !seen.has(track.id)) {
seen.add(track.id);
result.push(track);
}
}
return result;
}
async function spotify(fetchFn: typeof fetch, path: string) {
const access = await token(() => {
if (!env.SPOTIFY_CLIENT_ID || !env.SPOTIFY_CLIENT_SECRET)
throw new ApiError('Spotify', 503, 'Spotify is not configured on this server.');
return requestJson(fetchFn, 'Spotify', 'https://accounts.spotify.com/api/token', {
method: 'POST',
headers: {
Authorization: `Basic ${btoa(`${env.SPOTIFY_CLIENT_ID}:${env.SPOTIFY_CLIENT_SECRET}`)}`,
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({ grant_type: 'client_credentials' })
});
});
return record(
await requestJson(fetchFn, 'Spotify', `https://api.spotify.com/v1/${path}`, {
headers: { Authorization: `Bearer ${access}` }
})
);
}
export async function getSpotifyMetadata(
fetchFn: typeof fetch,
input:
| Extract<SpotifyInput, { kind: 'track' | 'playlist' | 'album' }>
| Extract<SpotifyInput, { kind: 'search' }>
): Promise<{ tracks: SpotifyTrack[]; collection?: SpotifyCollection; truncated: boolean }> {
if (input.kind === 'track' || input.kind === 'search') {
let response: RecordValue;
try {
response = await spotify(
fetchFn,
input.kind === 'track'
? `tracks/${input.id}`
: `search?${new URLSearchParams({ q: input.query, type: 'track', limit: '1' })}`
);
} catch (error) {
if (error instanceof ApiError && error.status === 404)
throw new ApiError('Spotify', 404, 'Spotify track not found.');
throw error;
}
const track = usableTrack(
input.kind === 'track'
? response
: (record(response.tracks).items as unknown[] | undefined)?.[0]
);
if (!track)
throw new ApiError(
'Spotify',
404,
input.kind === 'track'
? 'Spotify track is unavailable.'
: 'No Spotify text-search result. Try an artist and song title.'
);
return { tracks: [track], truncated: false };
}
try {
const isAlbum = input.kind === 'album';
const resource = isAlbum ? 'albums' : 'playlists';
const metadata = await spotify(fetchFn, `${resource}/${input.id}`);
if (typeof metadata.name !== 'string')
throw new ApiError('Spotify', 502, 'Spotify returned incomplete playlist metadata.');
const owner = record(metadata.owner),
images = metadata.images;
const collection: SpotifyCollection = {
id: input.id,
kind: input.kind,
name: metadata.name,
owner:
isAlbum && Array.isArray(metadata.artists)
? metadata.artists
.map(record)
.map((a) => (typeof a.name === 'string' ? a.name : ''))
.filter(Boolean)
.join(', ')
: typeof owner.display_name === 'string'
? owner.display_name
: '',
image:
Array.isArray(images) && typeof record(images[0]).url === 'string'
? (record(images[0]).url as string)
: undefined
};
// Extended-quota responses use tracks/track; newer development-mode responses use items/item.
const modern = metadata.items !== undefined;
let page = record(modern ? metadata.items : metadata.tracks);
let offset = 0;
const tracks: SpotifyTrack[] = [];
const seen = new Set<string>();
for (let pages = 0; pages < MAX_PLAYLIST_PAGES; pages++) {
if (!Array.isArray(page.items))
page = await spotify(
fetchFn,
`${resource}/${input.id}/${modern ? 'items' : 'tracks'}?limit=50&offset=${offset}`
);
if (!Array.isArray(page.items))
throw new ApiError(
'Spotify',
502,
'Spotify playlist tracks are unavailable for this application.'
);
const usable = playlistTracks(
isAlbum
? page.items.map((item) => ({
track: { ...record(item), album: { name: metadata.name, images: metadata.images } }
}))
: page.items,
seen
);
const room = MAX_PLAYLIST_TRACKS - tracks.length;
tracks.push(...usable.slice(0, room));
offset += page.items.length;
const more = Boolean(page.next) || (typeof page.total === 'number' && offset < page.total);
if (tracks.length >= MAX_PLAYLIST_TRACKS || !more || pages === MAX_PLAYLIST_PAGES - 1)
return { tracks, collection, truncated: usable.length > room || more };
if (!page.items.length)
throw new ApiError('Spotify', 502, 'Spotify returned incomplete playlist tracks.');
// Construct pagination locally: never forward credentials to an upstream-supplied URL.
page = {};
}
return { tracks, collection, truncated: true };
} catch (error) {
if (error instanceof ApiError && [401, 403, 404].includes(error.status))
throw new ApiError(
'Spotify',
error.status,
input.kind === 'album'
? 'Spotify album unavailable or not found. Try an individual track.'
: 'Spotify playlist unavailable or private, or this application does not have playlist access. Try a public playlist or an individual track.'
);
throw error;
}
}

20
src/lib/title.test.ts Normal file
View File

@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { formatTitle, getTitle, siteEnvironment } from './title';
describe('site titles', () => {
it('uses one suffix and an environment prefix', () => {
expect(getTitle('/', 'DEV')).toBe('(DEV) Home | Stan Runge');
expect(getTitle('/projects/catch-em-all', 'STAGING')).toBe('(STAGING) Catchemall | Stan Runge');
expect(formatTitle('A blog post')).toBe('A blog post | Stan Runge');
});
it('gives unlisted routes a readable title', () => {
expect(getTitle('/projects/typing-practice')).toBe('typing practice | Stan Runge');
});
it('labels development and staging without labelling production', () => {
expect(siteEnvironment(true, 'https://staging.stanrunge.dev')).toBe('DEV');
expect(siteEnvironment(false, 'https://staging.stanrunge.dev')).toBe('STAGING');
expect(siteEnvironment(false, 'https://stanrunge.dev')).toBeNull();
});
});

View File

@@ -1,9 +1,40 @@
import { dev } from "$app/env";
export type SiteEnvironment = 'DEV' | 'STAGING' | null;
export function getTitle(path: string): string {
let builtTitle = "Stan Runge";
if (dev) builtTitle = "(DEV) " + builtTitle;
return builtTitle;
export function siteEnvironment(isDev: boolean, origin?: string): SiteEnvironment {
if (isDev) return 'DEV';
try {
return new URL(origin ?? '').hostname === 'staging.stanrunge.dev' ? 'STAGING' : null;
} catch {
return null;
}
}
export function formatTitle(title: string, environment: SiteEnvironment = null): string {
return `${environment ? `(${environment}) ` : ''}${title} | Stan Runge`;
}
export function getTitle(path: string, environment: SiteEnvironment = null): string {
const titles: Record<string, string> = {
'/': 'Home',
'/projects': 'Projects',
'/blog': 'Blog',
'/lists': 'Lists',
'/login': 'Sign in',
'/register': 'Register',
'/account': 'Account',
'/projects/catch-em-all': 'Catchemall',
'/projects/animaldex': 'Animaldex',
'/projects/animaldex/register': 'Log a sighting',
'/projects/animaldex/admin': 'Animaldex admin',
'/projects/derby': 'Derby Lens',
'/projects/cs-prices': 'CS Prices',
'/projects/game-tracker': 'Game Tracker',
'/projects/money-tracker': 'Money Tracker',
'/projects/spotify-beatmap-finder': 'osu! map finder',
'/projects/progress': 'Progress'
};
const cleaned = path.replace(/\/$/, '') || '/';
const title =
titles[cleaned] ?? cleaned.split('/').filter(Boolean).at(-1)?.replace(/-/g, ' ') ?? 'Home';
return formatTitle(title, environment);
}

View File

@@ -6,5 +6,18 @@ export const createArticleSchema = z.object({
export const updateArticleSchema = z.object({
title: z.string().min(1).max(256),
content: z.string().max(50000)
slug: z.string().min(1).max(120),
content: z.string().max(50000),
tags: z.array(z.string().min(1).max(32)).max(8)
});
export function parseArticleTags(value: string) {
return [
...new Set(
value
.split(',')
.map((tag) => tag.trim().toLowerCase())
.filter(Boolean)
)
];
}

View File

@@ -1,11 +1,5 @@
import { z } from 'zod';
// ── Presigned upload ──────────────────────────────────────────────────────────
export const presignedUploadSchema = z.object({
mimeType: z.enum(['image/jpeg', 'image/png', 'image/webp', 'image/heic'])
});
// ── Register new animal (+ first sighting) ────────────────────────────────────
export const registerAnimalSchema = z.object({
@@ -29,7 +23,7 @@ export const registerAnimalSchema = z.object({
deviceLat: z.number().min(-90).max(90),
deviceLng: z.number().min(-180).max(180),
// Already-uploaded R2 keys (1–10 photos, browser uploaded directly)
// Already-uploaded R2 keys (1–10 photos)
photoKeys: z.array(z.string().min(1)).min(1).max(10)
});
@@ -38,7 +32,7 @@ export type RegisterAnimalInput = z.infer<typeof registerAnimalSchema>;
// ── Add sighting to existing animal ──────────────────────────────────────────
export const addSightingSchema = z.object({
animalId: z.string().uuid(),
animalId: z.coerce.number().int().positive(),
reporterName: z.string().max(100).optional(),
seenAt: z.iso.datetime(),
lat: z.number().min(-90).max(90),