diff --git a/src/lib/components/pokedex/PokedexEntryCatchRecord.svelte b/src/lib/components/pokedex/PokedexEntryCatchRecord.svelte index fc2a44a..4644da5 100644 --- a/src/lib/components/pokedex/PokedexEntryCatchRecord.svelte +++ b/src/lib/components/pokedex/PokedexEntryCatchRecord.svelte @@ -29,8 +29,10 @@ const dispatch = createEventDispatcher(); - function updateCatchRecord() { - dispatch('updateCatch', { pokedexEntry, catchRecord }); + type UpdateCatchSource = 'toggle' | 'notes' | 'notes-blur'; + + function updateCatchRecord(source: UpdateCatchSource) { + dispatch('updateCatch', { pokedexEntry, catchRecord, source }); } function onCaughtChange() { @@ -39,7 +41,7 @@ if (catchRecord.caught) { catchRecord.haveToEvolve = false; } - updateCatchRecord(); + updateCatchRecord('toggle'); } function onNeedsToEvolveChange() { @@ -48,7 +50,7 @@ if (catchRecord.haveToEvolve) { catchRecord.caught = false; } - updateCatchRecord(); + updateCatchRecord('toggle'); } @@ -124,30 +126,30 @@
-
+ updateCatchRecord('toggle')} + /> + + {#if pokedexEntry.canGigantamax && showForms}
-
+ updateCatchRecord('toggle')} + /> +
- {/if} + + {/if}

diff --git a/src/lib/repositories/CatchRecordRepository.ts b/src/lib/repositories/CatchRecordRepository.ts index 564d4c0..ef67dad 100644 --- a/src/lib/repositories/CatchRecordRepository.ts +++ b/src/lib/repositories/CatchRecordRepository.ts @@ -43,6 +43,35 @@ class CatchRecordRepository { return dbData; } + /** + * Bulk upsert catch records in a single request when possible. + * + * Requires a unique constraint matching the `onConflict` columns. + * If the database is not configured with that constraint, the caller should + * catch the error and fall back to per-record upserts. + */ + async bulkUpsert(records: Partial[]): Promise { + const dbRows: Partial[] = records.map((r) => ({ + userId: this.userId, + pokedexId: this.pokedexId, + ...this.transformToDatabase(r) + })); + + const { data: result, error } = await this.supabase + .from('catch_records') + .upsert(dbRows, { + onConflict: 'userId,pokedexId,pokedexEntryId' + }) + .select(); + + if (error) { + console.error('Supabase error bulk upserting catch records:', error); + throw new Error(`Failed to bulk upsert catch records: ${error.message}`); + } + + return (result ?? []).map((row) => this.transformCatchRecord(row)); + } + async findById(id: string): Promise { const { data, error } = await this.supabase .from('catch_records') @@ -138,7 +167,11 @@ class CatchRecordRepository { return this.update(data._id, data); } else if (data.pokedexEntryId) { // Try to find existing record for this user, pokemon, and pokedex - const existing = await this.findByUserAndPokemon(this.userId, data.pokedexEntryId, this.pokedexId); + const existing = await this.findByUserAndPokemon( + this.userId, + data.pokedexEntryId, + this.pokedexId + ); if (existing) { return this.update(existing._id, data); } else { @@ -148,7 +181,11 @@ class CatchRecordRepository { return this.create(data); } - async findByUserAndPokemon(userId: string, pokedexEntryId: string, pokedexId: string): Promise { + async findByUserAndPokemon( + userId: string, + pokedexEntryId: string, + pokedexId: string + ): Promise { const numericId = Number(pokedexEntryId); if (isNaN(numericId)) { return null; diff --git a/src/lib/repositories/PokedexRepository.ts b/src/lib/repositories/PokedexRepository.ts index 0b1842e..4a48b39 100644 --- a/src/lib/repositories/PokedexRepository.ts +++ b/src/lib/repositories/PokedexRepository.ts @@ -70,7 +70,10 @@ class PokedexRepository { if (error) { console.error('Error creating pokedex:', error); - throw new Error(`Failed to create pokedex: ${error.message}`); + // Preserve the Supabase error code so the API layer can map to a user-friendly response. + throw Object.assign(new Error(`Failed to create pokedex: ${error.message}`), { + code: error.code + }); } if (!result) { @@ -91,12 +94,22 @@ class PokedexRepository { .select() .single(); - if (error || !result) return null; + if (error) { + console.error('Error updating pokedex:', error); + throw Object.assign(new Error(`Failed to update pokedex: ${error.message}`), { + code: error.code + }); + } + if (!result) return null; return this.transform(result); } async delete(id: string): Promise { - const { error } = await this.supabase.from('pokedexes').delete().eq('id', id).eq('userId', this.userId); + const { error } = await this.supabase + .from('pokedexes') + .delete() + .eq('id', id) + .eq('userId', this.userId); if (error) { console.error('Error deleting pokedex:', error); diff --git a/src/lib/utils/catchRecordWriteQueue.ts b/src/lib/utils/catchRecordWriteQueue.ts new file mode 100644 index 0000000..005cc4b --- /dev/null +++ b/src/lib/utils/catchRecordWriteQueue.ts @@ -0,0 +1,246 @@ +import { writable, type Readable } from 'svelte/store'; +import type { CatchRecord } from '$lib/models/CatchRecord'; + +export type CatchRecordWriteQueueStatus = { + pending: number; + inFlight: number; + lastError: string | null; + lastFlushAttemptAt: number | null; + lastSuccessfulFlushAt: number | null; +}; + +type QueueItem = { + record: CatchRecord; + attempts: number; + notBefore: number; // unix ms + debounceTimer: ReturnType | null; +}; + +export type CreateCatchRecordWriteQueueOptions = { + /** + * URL to the pokédex-scoped catch-records endpoint. + * Example: `/api/pokedexes/${pokedexId}/catch-records` + */ + endpointUrl: string; + /** + * Fetch implementation to use (usually the `fetch` provided by SvelteKit load). + */ + fetchFn: typeof fetch; + /** Max number of records sent per request. */ + batchSize?: number; + /** Max number of concurrent in-flight requests. */ + concurrency?: number; +}; + +export type EnqueueCatchRecordWriteOptions = { + /** Debounce before the record becomes eligible for flushing (ms). */ + debounceMs?: number; + /** If true, schedule an immediate flush attempt after enqueue. */ + flushSoon?: boolean; +}; + +export type FlushOptions = { + /** + * Use `keepalive` to improve best-effort delivery during `pagehide`. + * Note: browsers impose body-size limits on keepalive requests. + */ + keepalive?: boolean; + /** + * Limit number of items flushed in this attempt. + * Useful for keepalive flushes on tab close. + */ + limit?: number; +}; + +function keyFor(record: CatchRecord): string { + return `${record.userId}:${record.pokedexId}:${record.pokedexEntryId}`; +} + +function backoffMs(attempts: number): number { + // Exponential backoff with jitter: 250ms, 500ms, 1s, 2s, 4s... capped. + const base = 250 * Math.pow(2, Math.max(0, attempts - 1)); + const capped = Math.min(base, 10_000); + const jitter = Math.floor(Math.random() * 200); + return capped + jitter; +} + +export function createCatchRecordWriteQueue(options: CreateCatchRecordWriteQueueOptions): { + enqueue: (record: CatchRecord, opts?: EnqueueCatchRecordWriteOptions) => void; + flushNow: (opts?: FlushOptions) => Promise; + getStatus: Readable; + getPendingCount: () => number; + clearError: () => void; +} { + const { endpointUrl, fetchFn, batchSize = 100, concurrency = 2 } = options; + + const items = new Map(); + let scheduled: ReturnType | null = null; + let inFlight = 0; + + const statusStore = writable({ + pending: 0, + inFlight: 0, + lastError: null, + lastFlushAttemptAt: null, + lastSuccessfulFlushAt: null + }); + + function updateStatus(patch: Partial) { + statusStore.update((s) => ({ + ...s, + pending: items.size, + inFlight, + ...patch + })); + } + + function computeNextWakeup(): number | null { + let next: number | null = null; + const now = Date.now(); + for (const [, item] of items) { + if (item.debounceTimer) continue; + if (item.notBefore <= now) { + return now; + } + next = next === null ? item.notBefore : Math.min(next, item.notBefore); + } + return next; + } + + function scheduleFlush() { + if (scheduled) return; + const next = computeNextWakeup(); + if (next === null) return; + const delay = Math.max(0, next - Date.now()); + scheduled = setTimeout(async () => { + scheduled = null; + await flushNow(); + }, delay); + } + + async function flushBatch(opts?: FlushOptions): Promise { + if (typeof navigator !== 'undefined' && navigator.onLine === false) { + // Stay queued; caller can retry when online. + return; + } + if (inFlight >= concurrency) return; + + const now = Date.now(); + const eligible: Array<[string, QueueItem]> = []; + for (const entry of items.entries()) { + const [k, item] = entry; + if (item.debounceTimer) continue; + if (item.notBefore > now) continue; + eligible.push([k, item]); + if (eligible.length >= (opts?.limit ?? batchSize)) break; + } + if (eligible.length === 0) return; + + inFlight++; + updateStatus({ lastFlushAttemptAt: now }); + try { + const payload = eligible.map(([, item]) => item.record); + const response = await fetchFn(endpointUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + // keepalive improves odds during `pagehide`. + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + keepalive: !!opts?.keepalive, + credentials: 'include' + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Failed to flush catch records: ${response.status} ${text}`); + } + + // Success: drop the flushed keys. + for (const [k] of eligible) { + items.delete(k); + } + updateStatus({ lastError: null, lastSuccessfulFlushAt: Date.now() }); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + // Backoff all eligible items and keep them. + for (const [k, item] of eligible) { + const nextAttempts = item.attempts + 1; + items.set(k, { + ...item, + attempts: nextAttempts, + notBefore: Date.now() + backoffMs(nextAttempts) + }); + } + updateStatus({ lastError: message }); + } finally { + inFlight--; + updateStatus({}); + } + } + + async function flushNow(opts?: FlushOptions): Promise { + updateStatus({}); + // Drain in waves while eligible work exists and we have capacity. + // We intentionally do not block indefinitely; this is a “best-effort background flush”. + for (let i = 0; i < 10; i++) { + if (items.size === 0) break; + await flushBatch(opts); + // If nothing is eligible yet, schedule the next wakeup and exit. + const next = computeNextWakeup(); + if (next !== null && next > Date.now()) break; + if (inFlight >= concurrency) break; + } + scheduleFlush(); + } + + function enqueue(record: CatchRecord, opts?: EnqueueCatchRecordWriteOptions) { + const k = keyFor(record); + const now = Date.now(); + const existing = items.get(k); + const debounceMs = opts?.debounceMs ?? 0; + + // Clear any pending debounce timer: latest state always wins. + if (existing?.debounceTimer) { + clearTimeout(existing.debounceTimer); + } + + let debounceTimer: ReturnType | null = null; + let notBefore = now; + if (debounceMs > 0) { + notBefore = now + debounceMs; + debounceTimer = setTimeout(() => { + const item = items.get(k); + if (!item) return; + items.set(k, { ...item, debounceTimer: null, notBefore: Date.now() }); + updateStatus({}); + scheduleFlush(); + }, debounceMs); + } + + items.set(k, { + record, + attempts: existing?.attempts ?? 0, + notBefore, + debounceTimer + }); + updateStatus({}); + if (opts?.flushSoon !== false) scheduleFlush(); + } + + function getPendingCount(): number { + return items.size; + } + + function clearError() { + updateStatus({ lastError: null }); + } + + return { + enqueue, + flushNow, + getStatus: { subscribe: statusStore.subscribe }, + getPendingCount, + clearError + }; +} diff --git a/src/routes/api/pokedexes/+server.ts b/src/routes/api/pokedexes/+server.ts index 729f4cf..9365ab8 100644 --- a/src/routes/api/pokedexes/+server.ts +++ b/src/routes/api/pokedexes/+server.ts @@ -22,26 +22,38 @@ export const GET = async (event: RequestEvent) => { // POST: Create new pokedex export const POST = async (event: RequestEvent) => { + let requestedName: string | undefined; try { - console.log('POST /api/pokedexes - Starting request'); const userId = await requireAuth(event); - console.log('POST /api/pokedexes - User authenticated:', userId); - const data: Partial = await event.request.json(); - console.log('POST /api/pokedexes - Request data:', data); - + requestedName = typeof data.name === 'string' ? data.name : undefined; data.userId = userId; const repo = new PokedexRepository(event.locals.supabase, userId); const pokedex = await repo.create(data); - console.log('POST /api/pokedexes - Pokédex created:', pokedex._id); - return json(pokedex); } catch (err) { console.error('Error in POST /api/pokedexes:', err); - if (err && typeof err === 'object' && 'status' in err) { - throw err; + + // Unique constraint violation (duplicate pokédex name for this user) + if ( + err && + typeof err === 'object' && + 'code' in err && + // Postgres unique_violation + (err as { code?: unknown }).code === '23505' + ) { + return json( + { + error: requestedName + ? `You already have a Pokédex named "${requestedName}". Please choose a different name.` + : 'You already have a Pokédex with that name. Please choose a different name.' + }, + { status: 409 } + ); } + + if (err && typeof err === 'object' && 'status' in err) throw err; return json({ error: 'Internal Server Error' }, { status: 500 }); } }; diff --git a/src/routes/api/pokedexes/[id]/+server.ts b/src/routes/api/pokedexes/[id]/+server.ts index 2fea2fd..c4b71e9 100644 --- a/src/routes/api/pokedexes/[id]/+server.ts +++ b/src/routes/api/pokedexes/[id]/+server.ts @@ -40,10 +40,12 @@ export const GET = async (event: RequestEvent) => { // PUT: Update pokedex export const PUT = async (event: RequestEvent) => { + let requestedName: string | undefined; try { const userId = await requireAuth(event); const { id } = event.params; const data: Partial = await event.request.json(); + requestedName = typeof data.name === 'string' ? data.name : undefined; if (!id) { return json({ error: 'Pokedex ID is required' }, { status: 400 }); @@ -64,9 +66,25 @@ export const PUT = async (event: RequestEvent) => { return json(pokedex); } catch (err) { console.error(err); - if (err && typeof err === 'object' && 'status' in err) { - throw err; + + // Unique constraint violation (duplicate pokédex name for this user) + if ( + err && + typeof err === 'object' && + 'code' in err && + (err as { code?: unknown }).code === '23505' + ) { + return json( + { + error: requestedName + ? `You already have a Pokédex named "${requestedName}". Please choose a different name.` + : 'You already have a Pokédex with that name. Please choose a different name.' + }, + { status: 409 } + ); } + + if (err && typeof err === 'object' && 'status' in err) throw err; return json({ error: 'Internal Server Error' }, { status: 500 }); } }; diff --git a/src/routes/api/pokedexes/[id]/catch-records/+server.ts b/src/routes/api/pokedexes/[id]/catch-records/+server.ts index 1eb4c70..7989757 100644 --- a/src/routes/api/pokedexes/[id]/catch-records/+server.ts +++ b/src/routes/api/pokedexes/[id]/catch-records/+server.ts @@ -109,16 +109,26 @@ export const POST = async (event: RequestEvent) => { const repo = new CatchRecordRepository(event.locals.supabase, userId, pokedexId); - const insertedRecords = []; - for (const record of records) { - // Ensure the data is scoped to this pokédex and user - record.userId = userId; - record.pokedexId = pokedexId; - const upsertedRecord = await repo.upsert(record); - insertedRecords.push(upsertedRecord); - } + // Ensure the data is scoped to this pokédex and user + const scoped = records.map((r) => ({ + ...r, + userId, + pokedexId + })); - return json(insertedRecords); + // Prefer a single bulk upsert when the DB supports it; fall back to per-record upserts. + try { + const upserted = await repo.bulkUpsert(scoped); + return json(upserted); + } catch (bulkErr) { + console.warn('Bulk upsert failed; falling back to per-record upserts:', bulkErr); + const insertedRecords = []; + for (const record of scoped) { + const upsertedRecord = await repo.upsert(record); + insertedRecords.push(upsertedRecord); + } + return json(insertedRecords); + } } catch (err) { console.error(err); if (err && typeof err === 'object' && 'status' in err) { diff --git a/src/routes/my-pokedexes/+page.svelte b/src/routes/my-pokedexes/+page.svelte index 78294ae..ba83c18 100644 --- a/src/routes/my-pokedexes/+page.svelte +++ b/src/routes/my-pokedexes/+page.svelte @@ -38,6 +38,22 @@ } } + async function readApiErrorMessage(response: Response): Promise { + try { + const data = await response.json(); + if (data && typeof data === 'object' && 'error' in data && typeof data.error === 'string') { + return data.error; + } + } catch { + // fall through + } + try { + return await response.text(); + } catch { + return 'Request failed'; + } + } + function openCreateModal() { editingPokedex = null; formData = { @@ -75,9 +91,9 @@ }); if (!response.ok) { - const errorText = await response.text(); + const errorText = await readApiErrorMessage(response); console.error('Failed to update pokédex:', response.status, errorText); - alert(`Failed to update pokédex: ${errorText}`); + alert(errorText); return; } @@ -94,9 +110,9 @@ }); if (!response.ok) { - const errorText = await response.text(); + const errorText = await readApiErrorMessage(response); console.error('Failed to create pokédex:', response.status, errorText); - alert(`Failed to create pokédex: ${errorText}`); + alert(errorText); return; } const createdPokedex = (await response.json()) as Pokedex; diff --git a/src/routes/pokedex/[id]/+page.svelte b/src/routes/pokedex/[id]/+page.svelte index 5518707..77f021b 100644 --- a/src/routes/pokedex/[id]/+page.svelte +++ b/src/routes/pokedex/[id]/+page.svelte @@ -1,5 +1,5 @@ @@ -426,6 +483,15 @@
+ {#if catchWriteStatus.pending > 0 || catchWriteStatus.inFlight > 0} +
+ Saving… ({catchWriteStatus.pending} queued) +
+ {:else if catchWriteStatus.lastError} +
+ Save failed (will retry) +
+ {/if} = {}): CatchRecord { + return { + _id: '', + userId: 'u1', + pokedexId: 'p1', + pokedexEntryId: '25', + haveToEvolve: false, + caught: false, + inHome: false, + hasGigantamaxed: false, + personalNotes: '', + ...overrides + }; +} + +describe('createCatchRecordWriteQueue()', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.restoreAllMocks(); + }); + + it('coalesces multiple updates for the same key and flushes only the latest state', async () => { + const fetchFn = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + return new Response(init?.body as string, { status: 200 }); + }); + + const queue = createCatchRecordWriteQueue({ + endpointUrl: '/api/pokedexes/p1/catch-records', + fetchFn, + batchSize: 50, + concurrency: 1 + }); + + queue.enqueue(mkRecord({ caught: true })); + queue.enqueue(mkRecord({ caught: false, haveToEvolve: true })); + + await queue.flushNow(); + + expect(fetchFn).toHaveBeenCalledTimes(1); + const body = JSON.parse(String(fetchFn.mock.calls[0]?.[1]?.body)); + expect(body).toHaveLength(1); + expect(body[0].haveToEvolve).toBe(true); + expect(body[0].caught).toBe(false); + }); + + it('debounces notes updates before flushing', async () => { + const fetchFn = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + return new Response(init?.body as string, { status: 200 }); + }); + + const queue = createCatchRecordWriteQueue({ + endpointUrl: '/api/pokedexes/p1/catch-records', + fetchFn, + batchSize: 50, + concurrency: 1 + }); + + queue.enqueue(mkRecord({ personalNotes: 'a' }), { debounceMs: 500 }); + await queue.flushNow(); + expect(fetchFn).toHaveBeenCalledTimes(0); + + vi.advanceTimersByTime(499); + await queue.flushNow(); + expect(fetchFn).toHaveBeenCalledTimes(0); + + vi.advanceTimersByTime(1); + await queue.flushNow(); + expect(fetchFn).toHaveBeenCalledTimes(1); + }); +});