From 4c268ba15ce6a375d446f489bba2249722bf37ec Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Tue, 15 Sep 2026 17:46:07 +0100 Subject: [PATCH 01/13] =?UTF-8?q?feat(pokedex):=20add=20opt-in=20Server-Ti?= =?UTF-8?q?ming=20for=20the=20pok=C3=A9dex=20load?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POKEDEX_PERFORMANCE=true reports auth, ownership, scopes, entries and catches with fixed labels only: no IDs, query strings, cookies or entry content ever reach the header. Auth is measured in the hook, where the session is actually resolved, and handed to the page load through locals. --- src/app.d.ts | 1 + src/hooks.server.ts | 3 +++ src/lib/server/pokedexPerformance.ts | 37 +++++++++++++++++++++++++++ tests/unit/pokedexPerformance.test.ts | 36 ++++++++++++++++++++++++++ 4 files changed, 77 insertions(+) create mode 100644 src/lib/server/pokedexPerformance.ts create mode 100644 tests/unit/pokedexPerformance.test.ts diff --git a/src/app.d.ts b/src/app.d.ts index 2c1facc..1a7de71 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -13,6 +13,7 @@ declare global { // interface Error {} interface Locals { supabase: SupabaseClient; + pokedexAuthMs?: number; safeGetSession(): Promise<{ session: Session | null; user: User | null }>; userid: string; buildDate: string; diff --git a/src/hooks.server.ts b/src/hooks.server.ts index b8b7b5d..e8d38c9 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -30,17 +30,20 @@ export const handle: Handle = async ({ event, resolve }) => { let sessionPromise: ReturnType | null = null; event.locals.safeGetSession = () => { sessionPromise ??= (async () => { + const authStarted = performance.now(); const { data: { user }, error } = await event.locals.supabase.auth.getUser(); if (error) { + event.locals.pokedexAuthMs = performance.now() - authStarted; return { session: null, user: null }; } const { data: { session } } = await event.locals.supabase.auth.getSession(); + event.locals.pokedexAuthMs = performance.now() - authStarted; return { session, user }; })(); return sessionPromise; diff --git a/src/lib/server/pokedexPerformance.ts b/src/lib/server/pokedexPerformance.ts new file mode 100644 index 0000000..5254015 --- /dev/null +++ b/src/lib/server/pokedexPerformance.ts @@ -0,0 +1,37 @@ +import { env } from '$env/dynamic/private'; + +/** Opt-in, fixed labels only: never log IDs, query strings, cookies or entry content. */ +export class PokedexPerformance { + private started = performance.now(); + private durations: Record = {}; + readonly enabled = env.POKEDEX_PERFORMANCE === 'true'; + async measure( + stage: 'auth' | 'ownership' | 'scopes' | 'entries' | 'catches', + run: () => Promise + ): Promise { + const start = performance.now(); + try { + return await run(); + } finally { + if (this.enabled) this.durations[stage] = performance.now() - start; + } + } + recordAuth(duration: number | undefined) { + if (this.enabled && duration !== undefined) this.durations.auth = duration; + } + prepare(run: () => T): T { + const start = performance.now(); + try { + return run(); + } finally { + if (this.enabled) this.durations.prepare = performance.now() - start; + } + } + finish(): string | undefined { + if (!this.enabled) return undefined; + this.durations.total = performance.now() - this.started; + return Object.entries(this.durations) + .map(([name, duration]) => `${name};dur=${duration.toFixed(1)}`) + .join(', '); + } +} diff --git a/tests/unit/pokedexPerformance.test.ts b/tests/unit/pokedexPerformance.test.ts new file mode 100644 index 0000000..a7a9c8e --- /dev/null +++ b/tests/unit/pokedexPerformance.test.ts @@ -0,0 +1,36 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { PokedexPerformance } from '$lib/server/pokedexPerformance'; + +afterEach(() => vi.unstubAllEnvs()); +describe('opt-in stage timing', () => { + it('emits no timing header without explicit opt-in', async () => { + vi.stubEnv('POKEDEX_PERFORMANCE', 'false'); + const timing = new PokedexPerformance(); + expect(await timing.measure('entries', async () => 'result')).toBe('result'); + expect(timing.prepare(() => 3)).toBe(3); + timing.recordAuth(10); + expect(timing.finish()).toBeUndefined(); + }); + it('records failed stages without exposing exception content', async () => { + vi.stubEnv('POKEDEX_PERFORMANCE', 'true'); + const timing = new PokedexPerformance(); + await expect( + timing.measure('catches', async () => { + throw new Error('private note'); + }) + ).rejects.toThrow('private note'); + expect(() => + timing.prepare(() => { + throw new Error('private ID'); + }) + ).toThrow('private ID'); + timing.recordAuth(undefined); + timing.recordAuth(12.34); + const header = timing.finish()!; + expect(header).toContain('auth;dur=12.3'); + expect(header).toMatch(/catches;dur=\d+\.\d/); + expect(header).toMatch(/prepare;dur=\d+\.\d/); + expect(header).toMatch(/total;dur=\d+\.\d/); + expect(header).not.toContain('private'); + }); +}); From acaf760b36699b6415e4403f6556a8029327d7d5 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Tue, 15 Sep 2026 17:46:19 +0100 Subject: [PATCH 02/13] perf(pokedex): send the box grid as packed rows with the page The server load fetched a full combined-data page at a 9999 item page size, carrying detail text and ownership fields the grid never renders, and the client re-fetched the same payload after hydration. Load a trimmed grid row instead and pack it as positional tuples so field names are not repeated for every one of a thousand-plus entries. Entry detail is fetched on demand from the new per-entry endpoint when a cell is opened, and the grid marks itself interactive so other page-start work can queue behind it. --- .../pokedex/PokedexViewBoxes.svelte | 631 +++++++++++------- src/lib/models/PokedexGridRow.ts | 58 ++ .../repositories/CombinedDataRepository.ts | 126 +++- src/lib/services/PokedexGridService.ts | 38 ++ src/lib/utils/criticalPageWork.ts | 50 ++ .../[id]/entries/[entryId]/+server.ts | 18 + src/routes/api/pokedexes/[id]/grid/+server.ts | 29 + src/routes/pokedex/[id]/+page.server.ts | 68 +- src/routes/pokedex/[id]/+page.svelte | 405 +++++++---- src/routes/shared/[token]/+page.svelte | 7 +- tests/bdd/features/performance.feature | 2 +- tests/bdd/steps/performance.steps.ts | 7 +- .../pokedexGrid.integration.test.ts | 132 ++++ tests/unit/combinedDataRepository.test.ts | 125 +++- tests/unit/criticalPageWork.test.ts | 103 +++ tests/unit/gridTransport.test.ts | 27 + 16 files changed, 1382 insertions(+), 444 deletions(-) create mode 100644 src/lib/models/PokedexGridRow.ts create mode 100644 src/lib/services/PokedexGridService.ts create mode 100644 src/lib/utils/criticalPageWork.ts create mode 100644 src/routes/api/pokedexes/[id]/entries/[entryId]/+server.ts create mode 100644 src/routes/api/pokedexes/[id]/grid/+server.ts create mode 100644 tests/integration/pokedexGrid.integration.test.ts create mode 100644 tests/unit/criticalPageWork.test.ts create mode 100644 tests/unit/gridTransport.test.ts diff --git a/src/lib/components/pokedex/PokedexViewBoxes.svelte b/src/lib/components/pokedex/PokedexViewBoxes.svelte index bcc0191..5994310 100644 --- a/src/lib/components/pokedex/PokedexViewBoxes.svelte +++ b/src/lib/components/pokedex/PokedexViewBoxes.svelte @@ -1,15 +1,109 @@
-
+
{#if combinedData && combinedData.length > 0}
@@ -212,6 +306,7 @@ Box view layout + {#if virtualize} + + {/if}
Legend: @@ -288,6 +393,7 @@ Filters: