refactor(#56): Improve how the dex updating is handled and the error messaging around it

This commit is contained in:
Josh Creek
2026-01-10 12:07:05 +00:00
parent 845cc1c739
commit b1b6e63b6f
11 changed files with 700 additions and 107 deletions
+21 -9
View File
@@ -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<Pokedex> = 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 });
}
};
+20 -2
View File
@@ -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<Pokedex> = 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 });
}
};
@@ -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) {
+20 -4
View File
@@ -38,6 +38,22 @@
}
}
async function readApiErrorMessage(response: Response): Promise<string> {
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;
+122 -56
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { onDestroy, onMount } from 'svelte';
import { user } from '$lib/stores/user.js';
import { type User } from '@supabase/auth-js';
import { type CombinedData } from '$lib/models/CombinedData';
@@ -7,6 +7,10 @@
import type { CatchRecord } from '$lib/models/CatchRecord';
import type { PokedexEntry } from '$lib/models/PokedexEntry';
import { calculateBoxNumbers, calculateBoxPlacement } from '$lib/utils/boxPlacement';
import {
createCatchRecordWriteQueue,
type CatchRecordWriteQueueStatus
} from '$lib/utils/catchRecordWriteQueue';
import PokedexViewBoxes from '$lib/components/pokedex/PokedexViewBoxes.svelte';
import PokedexModal from '$lib/components/pokedex/PokedexModal.svelte';
import PokedexEntryCatchRecord from '$lib/components/pokedex/PokedexEntryCatchRecord.svelte';
@@ -35,6 +39,16 @@
let showModal = false;
let selectedPokemon: CombinedData | null = null;
let catchWriteQueue: ReturnType<typeof createCatchRecordWriteQueue> | null = null;
let catchWriteQueueKey: string | null = null;
let catchWriteStatus: CatchRecordWriteQueueStatus = {
pending: 0,
inFlight: 0,
lastError: null,
lastFlushAttemptAt: null,
lastSuccessfulFlushAt: null
};
// Derive from pokedex config
$: showOrigins = !!pokedex?.isOriginDex;
$: showShiny = !!pokedex?.isShinyDex;
@@ -54,19 +68,50 @@
selectedPokemon = null;
}
async function handleModalCatchUpdate(event: any) {
await updateACatch(event); // Existing handler
// Sync modal with refreshed data
if (selectedPokemon && combinedData) {
const updated = combinedData.find(
(cd) => cd.pokedexEntry._id === selectedPokemon!.pokedexEntry._id
);
if (updated) {
selectedPokemon = updated;
function ensureCatchWriteQueue() {
if (!browser) return;
if (!pokedexId) return;
if (!localUser?.id) return;
const desiredKey = `${localUser.id}:${pokedexId}`;
if (catchWriteQueue && catchWriteQueueKey === desiredKey) return;
catchWriteQueue = createCatchRecordWriteQueue({
endpointUrl: `/api/pokedexes/${pokedexId}/catch-records`,
fetchFn: fetch,
batchSize: 200,
concurrency: 2
});
catchWriteQueueKey = desiredKey;
catchWriteQueue.getStatus.subscribe((s) => {
catchWriteStatus = s;
});
}
function applyOptimisticCatchRecordUpdate(next: CatchRecord) {
if (!combinedData) return;
const idx = combinedData.findIndex((cd) => cd.pokedexEntry._id === next.pokedexEntryId);
if (idx === -1) return;
// Replace the catchRecord entry with the updated version.
const current = combinedData[idx];
const patched: CombinedData = {
...current,
catchRecord: {
...(current.catchRecord ?? next),
...next
}
};
combinedData = [...combinedData.slice(0, idx), patched, ...combinedData.slice(idx + 1)];
if (selectedPokemon?.pokedexEntry._id === next.pokedexEntryId) {
selectedPokemon = patched;
}
}
async function handleModalCatchUpdate(event: any) {
await updateACatch(event);
}
type GetDataOptions = {
page?: number;
perPage?: number;
@@ -103,7 +148,11 @@
async function updateACatch(event: any) {
if (!pokedexId) return;
const { catchRecord } = event.detail;
ensureCatchWriteQueue();
const { catchRecord, source } = event.detail as {
catchRecord: CatchRecord;
source: 'toggle' | 'notes' | 'notes-blur';
};
// Enforce mutual exclusivity (should be impossible to have both true).
const sanitizedCatchRecord: CatchRecord = { ...catchRecord };
if (sanitizedCatchRecord.caught) {
@@ -117,29 +166,18 @@
return;
}
const requestOptions = {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(sanitizedCatchRecord),
credentials: 'include' as RequestCredentials
};
// Optimistic UI: update local state immediately.
applyOptimisticCatchRecordUpdate(sanitizedCatchRecord);
try {
// Use new pokédex-scoped endpoint
const response = await fetch(`/api/pokedexes/${pokedexId}/catch-records`, requestOptions);
if (!response.ok) {
const errorText = await response.text();
console.error('Server response:', response.status, errorText);
alert('Failed to update catch record');
throw new Error('Failed to update catch record');
}
// Refresh the data to reflect changes from server
await getData({ page: currentPage, perPage: itemsPerPage, setCombinedDataToNull: false });
} catch (error) {
console.error('Error updating catch record:', error);
// Queue a background write with coalescing.
const debounceMs = source === 'notes' ? 650 : 0;
catchWriteQueue?.enqueue(sanitizedCatchRecord, {
debounceMs,
flushSoon: true
});
if (source === 'notes-blur') {
// Force a flush attempt when the user leaves the textarea.
await catchWriteQueue?.flushNow();
}
}
@@ -151,6 +189,7 @@
) {
if (!combinedData) return;
if (!pokedexId) return;
ensureCatchWriteQueue();
const catchRecordsToUpdate: CatchRecord[] = combinedData
.filter((_, index) => calculateBoxPlacement(index).box === boxNumber)
@@ -183,31 +222,17 @@
}
return updatedRecord;
});
// Use new pokédex-scoped endpoint
try {
const response = await fetch(`/api/pokedexes/${pokedexId}/catch-records`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(catchRecordsToUpdate)
});
if (!response.ok) {
const errorText = await response.text();
console.error('Server response:', response.status, errorText);
alert(
`Failed to update catch records: ${response.status} ${response.statusText}\n${errorText}`
);
return;
}
await getData({ page: currentPage, perPage: itemsPerPage, setCombinedDataToNull: false });
} catch (error) {
console.error('Error updating catch records:', error);
alert(
`Network error updating catch records: ${error instanceof Error ? error.message : String(error)}`
);
// Optimistic patch: apply locally first.
for (const record of catchRecordsToUpdate) {
// Ensure mutual exclusivity locally.
if (record.caught) record.haveToEvolve = false;
if (record.haveToEvolve) record.caught = false;
applyOptimisticCatchRecordUpdate(record);
catchWriteQueue?.enqueue(record, { flushSoon: true });
}
// Kick a flush attempt (batching will occur inside the queue).
await catchWriteQueue?.flushNow();
}
async function markBoxAsNotCaught(boxNumber: number) {
@@ -293,6 +318,38 @@
// Fetch data whenever pagination controls change (client-side only)
$: if (browser && pokedexId) getData({ page: currentPage, perPage: itemsPerPage });
onMount(() => {
if (!browser) return;
const flushKeepalive = () => {
if (!catchWriteQueue) return;
// Best-effort: keepalive requests have body-size limits; flush a small batch.
void catchWriteQueue.flushNow({ keepalive: true, limit: 25 });
};
const onVisibilityChange = () => {
if (document.visibilityState === 'hidden') flushKeepalive();
};
window.addEventListener('pagehide', flushKeepalive);
document.addEventListener('visibilitychange', onVisibilityChange);
window.addEventListener('online', () => void catchWriteQueue?.flushNow());
// Periodic reconciliation to guard against any missed state (e.g. aborted tab-close flush).
const reconcileInterval = window.setInterval(() => {
if (!pokedexId) return;
if (creatingRecords) return;
if (catchWriteStatus.pending > 0 || catchWriteStatus.inFlight > 0) return;
void getData({ page: currentPage, perPage: itemsPerPage, setCombinedDataToNull: false });
}, 60_000);
return () => {
window.removeEventListener('pagehide', flushKeepalive);
document.removeEventListener('visibilitychange', onVisibilityChange);
window.clearInterval(reconcileInterval);
};
});
</script>
<svelte:head>
@@ -426,6 +483,15 @@
<!-- Right side: Actions -->
<div class="flex flex-row lg:flex-col gap-2">
{#if catchWriteStatus.pending > 0 || catchWriteStatus.inFlight > 0}
<div class="text-sm text-base-content/70">
Saving… ({catchWriteStatus.pending} queued)
</div>
{:else if catchWriteStatus.lastError}
<div class="text-sm text-error" title={catchWriteStatus.lastError}>
Save failed (will retry)
</div>
{/if}
<a href="/my-pokedexes" class="btn btn-outline btn-sm">
<svg
xmlns="http://www.w3.org/2000/svg"