mirror of
https://github.com/jcreek/LivingDexTracker.git
synced 2026-07-13 19:13:43 +00:00
refactor(#56): Improve how the dex updating is handled and the error messaging around it
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user