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.
This commit is contained in:
Josh Creek
2026-09-15 17:46:19 +01:00
parent 4c268ba15c
commit acaf760b36
16 changed files with 1382 additions and 444 deletions
+122 -3
View File
@@ -11,7 +11,9 @@ type TableQuery = { table: string; calls: Call[] };
* `await query` / `await query.range(...)` resolve like a real PostgREST response. Returning
* an empty data set keeps the repository's paging loops to a single iteration.
*/
function createSupabaseStub() {
function createSupabaseStub(
resultFor: (table: string) => unknown = () => ({ data: [], error: null, count: 0 })
) {
const queries: TableQuery[] = [];
const from = (table: string) => {
@@ -23,8 +25,7 @@ function createSupabaseStub() {
{
get(_target, prop: string) {
if (prop === 'then') {
return (resolve: (value: unknown) => unknown) =>
resolve({ data: [], error: null, count: 0 });
return (resolve: (value: unknown) => unknown) => resolve(resultFor(table));
}
return (...args: unknown[]) => {
record.calls.push({ method: prop, args });
@@ -115,3 +116,121 @@ describe('CombinedDataRepository base-form filtering', () => {
expect(mentionsIsDefaultForm(supplement)).toBe(false);
});
});
describe('compact grid reads', () => {
const entry = {
id: 1,
pokedexNumber: 1,
pokemon: 'Bulbasaur',
form: null,
spriteKey: '1',
canGigantamax: false
};
it('joins catch flags by ID and retains entries without catches', async () => {
const { supabase } = createSupabaseStub((table) => ({
data:
table === 'catch_records'
? [{ id: 'catch', pokemonId: 1, caught: true, personalNotes: 'private' }]
: [entry, { ...entry, id: 2 }],
error: null
}));
const repo = new CombinedDataRepository(supabase, 'owner', 'dex', true);
const rows = await repo.joinGridCatches(await repo.findGridEntries(false, '', []));
expect(rows[0].catchRecord).toMatchObject({ _id: 'catch', caught: true });
expect(rows[0].catchRecord).not.toHaveProperty('personalNotes');
expect(rows[1].catchRecord).toBeNull();
});
it('deduplicates overlapping scopes without dropping named form supplements', async () => {
const base = { ...entry, pokemon: 'Rotom', form: 'Lightbulb', dexNumber: 1, dexSortOrder: 1 };
const { supabase } = createSupabaseStub((table) => ({
data:
table === 'game_pokedex_entry_details' ? [base, base] : [{ ...base, id: 2, form: 'Heat' }],
error: null
}));
const repo = new CombinedDataRepository(supabase, 'owner', 'dex', true);
const rows = await repo.findGridEntries(true, 'Black', ['one', 'two']);
expect(rows.map((row) => [row.id, row.form])).toEqual([
[1, 'Lightbulb'],
[2, 'Heat']
]);
});
it.each([[[]], [['scope']]])(
'reports entry query failure instead of an empty grid (%j)',
async (scopes) => {
const { supabase } = createSupabaseStub(() => ({
data: null,
error: { message: 'unavailable' }
}));
const repo = new CombinedDataRepository(supabase, 'owner', 'dex', true);
await expect(repo.findGridEntries(false, '', scopes)).rejects.toThrow('Unable to load');
}
);
it('reports catch failure instead of displaying everything as uncaught', async () => {
const { supabase } = createSupabaseStub((table) =>
table === 'catch_records'
? { data: null, error: { message: 'unavailable' } }
: { data: [entry], error: null }
);
const repo = new CombinedDataRepository(supabase, 'owner', 'dex', true);
await expect(repo.joinGridCatches(await repo.findGridEntries(false, '', []))).rejects.toThrow(
'Unable to load catch records'
);
});
it('keeps a failed detail read distinct from a missing entry', async () => {
const missing = createSupabaseStub(() => ({ data: null, error: null }));
expect(
await new CombinedDataRepository(missing.supabase, 'owner', 'dex').findEntryDetail(1)
).toBeNull();
const failed = createSupabaseStub(() => ({ data: null, error: { message: 'unavailable' } }));
await expect(
new CombinedDataRepository(failed.supabase, 'owner', 'dex').findEntryDetail(1)
).rejects.toThrow('Unable to load entry details');
});
it.each([false, true])(
'returns full instructions with optional catch notes (caught: %s)',
async (caught) => {
const { supabase } = createSupabaseStub((table) => ({
data:
table === 'catch_records'
? caught
? [
{
id: 'catch',
pokemonId: 1,
userId: 'owner',
pokedexId: 'dex',
personalNotes: 'Saved note'
}
]
: []
: { ...entry, catchInformation: 'Full instructions' },
error: null
}));
const result = await new CombinedDataRepository(supabase, 'owner', 'dex').findEntryDetail(1);
expect(result?.pokedexEntry.catchInformation).toBe('Full instructions');
if (caught) expect(result?.catchRecord?.personalNotes).toBe('Saved note');
else expect(result?.catchRecord).toBeNull();
}
);
it('shares a scoped read between simultaneous rows and count requests', async () => {
const { supabase, queries } = createSupabaseStub();
const repo = new CombinedDataRepository(supabase, 'owner', 'dex');
await Promise.all([
repo.findCombinedData('owner', 1, 30, true, '', 'Scarlet', ['scarlet-paldea']),
repo.countCombinedData(true, '', 'Scarlet', ['scarlet-paldea'])
]);
expect(queryFor(queries, 'game_pokedex_entry_details')).toHaveLength(1);
expect(queryFor(queries, 'pokedex_entries')).toHaveLength(1);
});
it('selects compact columns and does not count the full grid', async () => {
const { supabase, queries } = createSupabaseStub();
const repo = new CombinedDataRepository(supabase, 'owner', 'dex', true);
await repo.joinGridCatches(await repo.findGridEntries(false, '', []));
expect(queries).toHaveLength(1);
const selection = queries[0].calls.find((call) => call.method === 'select');
expect(selection?.args[0]).not.toContain('*');
expect(selection?.args[0]).not.toContain('notes');
expect(selection?.args[0]).not.toContain('Information');
expect(selection?.args).toHaveLength(1);
});
});