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}
- {/if}
+
+ {/if}
diff --git a/src/lib/repositories/CatchRecordRepository.ts b/src/lib/repositories/CatchRecordRepository.ts
index 564d4c0..f9d25e0 100644
--- a/src/lib/repositories/CatchRecordRepository.ts
+++ b/src/lib/repositories/CatchRecordRepository.ts
@@ -1,6 +1,8 @@
import type { CatchRecord, CatchRecordDB } from '$lib/models/CatchRecord';
import type { SupabaseClient } from '@supabase/supabase-js';
+type PartialExcept = Partial & Pick;
+
class CatchRecordRepository {
constructor(
private supabase: SupabaseClient,
@@ -43,6 +45,66 @@ 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: Array>
+ ): Promise {
+ if (records.length === 0) return [];
+
+ const dbRows: Partial[] = records.map((r, index) => {
+ if (r.pokedexEntryId === undefined || r.pokedexEntryId === null || r.pokedexEntryId === '') {
+ throw new Error(
+ `CatchRecordRepository.bulkUpsert: record at index ${index} is missing required field "pokedexEntryId"`
+ );
+ }
+
+ // Preserve repository scoping; if a caller supplies ids, they must match the repo scope.
+ if (r.userId !== undefined && r.userId !== this.userId) {
+ throw new Error(
+ `CatchRecordRepository.bulkUpsert: record at index ${index} has userId "${r.userId}" but repository is scoped to "${this.userId}"`
+ );
+ }
+ if (r.pokedexId !== undefined && r.pokedexId !== this.pokedexId) {
+ throw new Error(
+ `CatchRecordRepository.bulkUpsert: record at index ${index} has pokedexId "${r.pokedexId}" but repository is scoped to "${this.pokedexId}"`
+ );
+ }
+
+ const mapped = this.transformToDatabase(r);
+ // Enforce repo scope after mapping so per-record input cannot override it.
+ mapped.userId = this.userId;
+ mapped.pokedexId = this.pokedexId;
+
+ if (mapped.pokedexEntryId === undefined || mapped.pokedexEntryId === null) {
+ throw new Error(
+ `CatchRecordRepository.bulkUpsert: record at index ${index} produced no pokedexEntryId after transform`
+ );
+ }
+
+ return mapped;
+ });
+
+ 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 +200,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 +214,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..5bc89e1
--- /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 = 1 } = 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..f8a113b 100644
--- a/src/routes/api/pokedexes/[id]/catch-records/+server.ts
+++ b/src/routes/api/pokedexes/[id]/catch-records/+server.ts
@@ -88,12 +88,29 @@ export const POST = async (event: RequestEvent) => {
try {
const userId = await requireAuth(event);
const { id: pokedexId } = event.params;
- const records: Partial[] = await event.request.json();
+ const body: unknown = await event.request.json();
if (!pokedexId) {
return json({ error: 'Pokedex ID is required' }, { status: 400 });
}
+ if (!Array.isArray(body)) {
+ return json({ error: 'Request body must be an array of catch records' }, { status: 400 });
+ }
+ const invalidIndex = body.findIndex((r) => {
+ if (!r || typeof r !== 'object') return true;
+ const entryId = (r as Partial).pokedexEntryId;
+ return entryId === undefined || entryId === null || entryId === '';
+ });
+ if (invalidIndex !== -1) {
+ return json(
+ { error: `Record at index ${invalidIndex} is missing required field "pokedexEntryId"` },
+ { status: 400 }
+ );
+ }
+
+ const records = body as Array & Pick>;
+
const { session } = await event.locals.safeGetSession();
if (session) {
await event.locals.supabase.auth.setSession(session);
@@ -109,16 +126,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..9677d5f 100644
--- a/src/routes/my-pokedexes/+page.svelte
+++ b/src/routes/my-pokedexes/+page.svelte
@@ -38,6 +38,23 @@
}
}
+ async function readApiErrorMessage(response: Response): Promise {
+ try {
+ // Use a clone so we can still fall back to reading the original body as text.
+ const data = await response.clone().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 +92,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 +111,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..fe44eeb 100644
--- a/src/routes/pokedex/[id]/+page.svelte
+++ b/src/routes/pokedex/[id]/+page.svelte
@@ -1,5 +1,5 @@
@@ -426,6 +495,15 @@
+ {#if catchWriteStatus.pending > 0 || catchWriteStatus.inFlight > 0}
+
+ Saving… ({catchWriteStatus.pending} queued)
+
+ {:else if catchWriteStatus.lastError}
+
+ Save failed (will retry)
+
+ {/if}