fix(pokedex): open detail cards without a per-card round trip

Since the grid transport change, opening a Pokémon fetched
/api/pokedexes/[id]/entries/[entryId]. That request re-scanned the whole
dex to authorise one entry, on top of an auth call and an ownership
query, and production runs its functions in a different region from the
database — so each card took seconds to fill in. The page's 60-second
reconcile also wiped the detail cache, making cards refetch about once a
minute.

Details are catalog text plus personal notes, so the dex is now read once
per account/dex in the background at the interactive/idle boundary and
fills that cache; a card opens straight from memory. That read reuses the
existing combined-data endpoint, which gains an opt-out for its count
query since a caller taking every row already knows the total.

The per-entry endpoint stays as the fallback for a card opened before the
background read lands, and now checks membership for the single entry
instead of materialising the dex. Catch status still comes from the live
grid row and any queued write, so a cached detail cannot show stale
progress.

The packed grid transport and virtualised rendering are unchanged.
This commit is contained in:
Josh Creek
2026-09-18 15:27:53 +01:00
parent 669d0cdeaa
commit c917bba54a
14 changed files with 442 additions and 50 deletions
+6
View File
@@ -14,6 +14,12 @@ Feature: Signed-in page speed
Then its entries appear within 5 seconds
And the browser did not request the grid separately
Scenario: A Pokémon's card opens from data the browser already has
When I load the Pokédex page directly
And the Pokédex has finished loading its details in the background
And I open the first Pokémon
Then its details were already in the browser
Scenario: Moving between my Pokédex list and a Pokédex is quick
When I switch between my Pokédex list and the Pokédex
Then each switch finishes within 3 seconds
+62 -13
View File
@@ -5,10 +5,14 @@ import { test, expect } from '../fixtures';
const { When, Then } = createBdd(test);
// Per-page scratch values; scenarios run one at a time (workers: 1).
const timings = new WeakMap<
Page,
{ entriesMs?: number; switchMs: number[]; entryRequests: number }
>();
type Timing = {
entriesMs?: number;
switchMs: number[];
gridRequests: number;
detailRequests: number;
detailsPrimed: boolean;
};
const timings = new WeakMap<Page, Timing>();
function entriesVisible(page: Page) {
return expect(page.getByRole('button', { name: /^View details for / }).first()).toBeVisible({
@@ -18,21 +22,61 @@ function entriesVisible(page: Page) {
When('I load the Pokédex page directly', async ({ page, state }) => {
if (!state.pokedexId) throw new Error('A Pokédex must exist before it can be opened');
const record = { switchMs: [], entryRequests: 0 };
const record: Timing = {
switchMs: [],
gridRequests: 0,
detailRequests: 0,
detailsPrimed: false
};
timings.set(page, record);
// Only requests made while the page first loads matter; the page's 60s reconciliation refetch
// can't fire within this window.
const countEntryRequests = (request: { url(): string }) => {
if (/\/api\/pokedexes\/[^/]+\/(?:grid|combined-data)/.test(request.url()))
record.entryRequests++;
const countGridRequests = (request: { url(): string }) => {
if (/\/api\/pokedexes\/[^/]+\/grid/.test(request.url())) record.gridRequests++;
};
page.on('request', countEntryRequests);
// The background detail read is deliberately allowed: it is what keeps cards from fetching
// themselves one at a time. Remember when it lands so a later step can open a card.
const notePrimedDetails = (response: { url(): string; status(): number }) => {
if (/\/api\/pokedexes\/[^/]+\/combined-data/.test(response.url()) && response.status() === 200)
record.detailsPrimed = true;
};
page.on('request', countGridRequests);
page.on('response', notePrimedDetails);
const started = Date.now();
await page.goto(`/pokedex/${state.pokedexId}`);
await entriesVisible(page);
(record as { entriesMs?: number }).entriesMs = Date.now() - started;
page.off('request', countEntryRequests);
record.entriesMs = Date.now() - started;
page.off('request', countGridRequests);
});
When('the Pokédex has finished loading its details in the background', async ({ page }) => {
const record = timings.get(page);
expect(record, 'the Pokédex page was never loaded').toBeDefined();
await expect.poll(() => record!.detailsPrimed, { timeout: 30_000 }).toBe(true);
});
When('I open the first Pokémon', async ({ page }) => {
const record = timings.get(page);
expect(record, 'the Pokédex page was never loaded').toBeDefined();
const countDetailRequests = (request: { url(): string }) => {
if (/\/api\/pokedexes\/[^/]+\/entries\//.test(request.url())) record!.detailRequests++;
};
page.on('request', countDetailRequests);
await page
.getByRole('button', { name: /^View details for / })
.first()
.click();
// "Where to catch" comes from the detail row, so it only renders once details are present.
await expect(page.getByText('Where to catch:')).toBeVisible({ timeout: 10_000 });
// Give any stray per-entry request time to be made before asserting none was.
await page.waitForTimeout(500);
page.off('request', countDetailRequests);
});
Then('its details were already in the browser', async ({ page }) => {
await expect(page.getByRole('status')).toHaveCount(0);
expect(timings.get(page)?.detailRequests).toBe(0);
});
Then('its entries appear within {int} seconds', async ({ page }, seconds: number) => {
@@ -44,12 +88,17 @@ Then('its entries appear within {int} seconds', async ({ page }, seconds: number
Then('the browser did not request the grid separately', async ({ page }) => {
// The server load includes the compact grid with the HTML, so the page must not make
// the old hydrate-then-fetch round trip.
expect(timings.get(page)?.entryRequests).toBe(0);
expect(timings.get(page)?.gridRequests).toBe(0);
});
When('I switch between my Pokédex list and the Pokédex', async ({ page, state }) => {
if (!state.pokedexName) throw new Error('A Pokédex must exist before switching to it');
const record = { switchMs: [] as number[], entryRequests: 0 };
const record: Timing = {
switchMs: [],
gridRequests: 0,
detailRequests: 0,
detailsPrimed: false
};
timings.set(page, record);
await page.goto('/my-pokedexes');
const card = page.locator('.card').filter({ hasText: state.pokedexName }).first();
+51
View File
@@ -234,3 +234,54 @@ describe('compact grid reads', () => {
expect(selection?.args).toHaveLength(1);
});
});
describe('CombinedDataRepository.isEntryInDex', () => {
it('accepts an entry the scoped dex lists, without reading the whole dex', async () => {
const { supabase, queries } = createSupabaseStub((table) =>
table === 'game_pokedex_entry_details'
? { data: [{ id: 25 }], error: null }
: { data: [], error: null }
);
const repo = new CombinedDataRepository(supabase, 'user-1', 'dex-1');
await expect(repo.isEntryInDex(25, false, 'Black', ['black-unova'])).resolves.toBe(true);
const [dexEntries] = queryFor(queries, 'game_pokedex_entry_details');
expect(hasCall(dexEntries, 'eq', ['id', 25])).toBe(true);
expect(hasCall(dexEntries, 'eq', ['isDefaultForm', true])).toBe(true);
expect(hasCall(dexEntries, 'limit', [1])).toBe(true);
// A membership check must not page the dex the way the grid read does.
expect(dexEntries.calls.some((c) => c.method === 'range')).toBe(false);
});
it('accepts a named form a game-scoped form dex supplements from pokedex_entries', async () => {
const { supabase, queries } = createSupabaseStub((table) =>
table === 'pokedex_entries' ? { data: [{ id: 99 }], error: null } : { data: [], error: null }
);
const repo = new CombinedDataRepository(supabase, 'user-1', 'dex-1');
await expect(repo.isEntryInDex(99, true, 'Scarlet', ['scarlet-paldea'])).resolves.toBe(true);
const [forms] = queryFor(queries, 'pokedex_entries');
expect(hasCall(forms, 'not', ['form', 'is', null])).toBe(true);
expect(hasCall(forms, 'contains', ['gamesToCatchIn', ['Scarlet']])).toBe(true);
});
it('rejects an entry that is in neither the scoped dex nor its form supplement', async () => {
const { supabase } = createSupabaseStub();
const repo = new CombinedDataRepository(supabase, 'user-1', 'dex-1');
await expect(repo.isEntryInDex(1, true, 'Scarlet', ['scarlet-paldea'])).resolves.toBe(false);
});
it('checks an unscoped dex against the game filter alone', async () => {
const { supabase, queries } = createSupabaseStub(() => ({ data: [{ id: 25 }], error: null }));
const repo = new CombinedDataRepository(supabase, 'user-1', 'dex-1');
await expect(repo.isEntryInDex(25, true, 'Red', [])).resolves.toBe(true);
const [entries] = queryFor(queries, 'pokedex_entries');
expect(hasCall(entries, 'contains', ['gamesToCatchIn', ['Red']])).toBe(true);
expect(mentionsIsDefaultForm(entries)).toBe(false);
});
});
+19
View File
@@ -74,4 +74,23 @@ describe('loadCombinedDataPage', () => {
releaseRows([]);
await expect(pending).resolves.toMatchObject({ totalCount: 45, totalPages: 5 });
});
it('skips the count query when the caller asks for every row at once', async () => {
findCombinedData.mockResolvedValue([{ id: 'a' }, { id: 'b' }]);
const result = await loadCombinedDataPage(supabase, 'user-1', pokedex, {
page: 1,
limit: 9999,
enableForms: true,
includeCount: false
});
expect(countCombinedData).not.toHaveBeenCalled();
expect(result).toEqual({
combinedData: [{ id: 'a' }, { id: 'b' }],
totalPages: 1,
currentPage: 1,
totalCount: 2
});
});
});
+93
View File
@@ -0,0 +1,93 @@
import { describe, expect, it } from 'vitest';
import { mergeEntryDetail } from '$lib/utils/pokemonDetail';
import type { CombinedData } from '$lib/models/CombinedData';
const detail = {
pokedexEntry: {
_id: '25',
pokedexNumber: 25,
pokemon: 'Pikachu',
form: '',
spriteKey: '25',
canGigantamax: true,
regionToCatchIn: 'Kanto',
gamesToCatchIn: ['Red'],
regionToEvolveIn: '',
evolutionInformation: 'Use a Thunder Stone',
catchInformation: ['Viridian Forest'],
notes: 'Dex note'
},
catchRecord: {
_id: 'catch-1',
userId: 'user-1',
pokedexId: 'dex-1',
pokemonId: '25',
caught: false,
haveToEvolve: false,
inHome: false,
hasGigantamaxed: false,
personalNotes: 'My note'
}
} satisfies CombinedData;
describe('mergeEntryDetail', () => {
it('keeps catalog text and personal notes while taking status from the grid row', () => {
const merged = mergeEntryDetail(
detail,
{ _id: 'catch-1', caught: true, haveToEvolve: false, inHome: true, hasGigantamaxed: false },
undefined,
'user-1',
'dex-1',
'25'
);
expect(merged.pokedexEntry.evolutionInformation).toBe('Use a Thunder Stone');
expect(merged.catchRecord).toMatchObject({
caught: true,
inHome: true,
personalNotes: 'My note'
});
});
it('lets a queued write win over both the detail row and the grid row', () => {
const merged = mergeEntryDetail(
detail,
{ _id: 'catch-1', caught: false, haveToEvolve: false, inHome: false, hasGigantamaxed: false },
{ userId: 'user-1', pokedexId: 'dex-1', pokemonId: '25', caught: true },
'user-1',
'dex-1',
'25'
);
expect(merged.catchRecord?.caught).toBe(true);
});
it('builds a record from a queued write when nothing has been saved yet', () => {
const merged = mergeEntryDetail(
{ ...detail, catchRecord: null },
null,
{ userId: 'user-1', pokedexId: 'dex-1', pokemonId: '25', haveToEvolve: true },
'user-1',
'dex-1',
'25'
);
expect(merged.catchRecord).toEqual({
_id: '',
userId: 'user-1',
pokedexId: 'dex-1',
pokemonId: '25',
caught: false,
haveToEvolve: true,
inHome: false,
hasGigantamaxed: false,
personalNotes: ''
});
});
it('leaves an uncaught Pokémon without a catch record', () => {
const merged = mergeEntryDetail({ ...detail, catchRecord: null }, null, null, 'u', 'd', '25');
expect(merged.catchRecord).toBeNull();
});
});