diff --git a/src/hooks.client.ts b/src/hooks.client.ts new file mode 100644 index 0000000..a61d47f --- /dev/null +++ b/src/hooks.client.ts @@ -0,0 +1,20 @@ +import type { HandleClientError } from '@sveltejs/kit'; + +export const handleError: HandleClientError = ({ error }) => { + const msg = error instanceof Error ? error.message : String(error); + + // SvelteKit lazy-loads route chunks via dynamic import(). When offline and the + // chunk isn't cached, the import fails with this error. Show a helpful message + // instead of the generic "Internal Error" page. + if ( + msg.includes('Importing a module script failed') || + msg.includes('Failed to fetch dynamically imported module') || + msg.includes('Unable to preload CSS') + ) { + return { + message: 'App not cached yet. Open the app while online at least once to enable offline use.' + }; + } + + return { message: msg || 'An unexpected error occurred.' }; +}; diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index 434eaad..5adb2fa 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -80,18 +80,46 @@ function isAuthPath(path: string) { return path.startsWith('/token/'); } +// A TypeError thrown by fetch() always means a network-level failure +function isNetworkFailure(e: unknown): boolean { + return e instanceof TypeError; +} + +// 5xx from the proxy/server when backend is down — treat same as offline +function isServerUnavailable(res: Response): boolean { + return res.status >= 500; +} + +async function queueMutation(method: 'POST' | 'PATCH' | 'DELETE', path: string, body: unknown): Promise { + network.setOffline(); + await enqueueMutation({ method, url: path, body }); + network.incrementPending(); +} + export async function apiPost(path: string, body: unknown): Promise { if (!navigator.onLine && !isAuthPath(path)) { - await enqueueMutation({ method: 'POST', url: path, body }); - network.incrementPending(); + await queueMutation('POST', path, body); return {} as T; } - const res = await apiFetch(path, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body) - }); + let res: Response; + try { + res = await apiFetch(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + } catch (e) { + if (!isAuthPath(path) && isNetworkFailure(e)) { + await queueMutation('POST', path, body); + return {} as T; + } + throw e; + } if (!res.ok) { + if (!isAuthPath(path) && isServerUnavailable(res)) { + await queueMutation('POST', path, body); + return {} as T; + } const err = await res.json().catch(() => ({})); throw Object.assign(new Error(`POST ${path} failed: ${res.status}`), { status: res.status, detail: err }); } @@ -100,25 +128,53 @@ export async function apiPost(path: string, body: unknown): Promise { export async function apiPatch(path: string, body: unknown): Promise { if (!navigator.onLine && !isAuthPath(path)) { - await enqueueMutation({ method: 'PATCH', url: path, body }); - network.incrementPending(); + await queueMutation('PATCH', path, body); return {} as T; } - const res = await apiFetch(path, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body) - }); - if (!res.ok) throw new Error(`PATCH ${path} failed: ${res.status}`); + let res: Response; + try { + res = await apiFetch(path, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + } catch (e) { + if (!isAuthPath(path) && isNetworkFailure(e)) { + await queueMutation('PATCH', path, body); + return {} as T; + } + throw e; + } + if (!res.ok) { + if (!isAuthPath(path) && isServerUnavailable(res)) { + await queueMutation('PATCH', path, body); + return {} as T; + } + throw new Error(`PATCH ${path} failed: ${res.status}`); + } return res.json(); } export async function apiDelete(path: string): Promise { if (!navigator.onLine && !isAuthPath(path)) { - await enqueueMutation({ method: 'DELETE', url: path, body: undefined }); - network.incrementPending(); + await queueMutation('DELETE', path, undefined); return; } - const res = await apiFetch(path, { method: 'DELETE' }); - if (!res.ok) throw new Error(`DELETE ${path} failed: ${res.status}`); + let res: Response; + try { + res = await apiFetch(path, { method: 'DELETE' }); + } catch (e) { + if (!isAuthPath(path) && isNetworkFailure(e)) { + await queueMutation('DELETE', path, undefined); + return; + } + throw e; + } + if (!res.ok) { + if (!isAuthPath(path) && isServerUnavailable(res)) { + await queueMutation('DELETE', path, undefined); + return; + } + throw new Error(`DELETE ${path} failed: ${res.status}`); + } } diff --git a/src/lib/offline/mutations.ts b/src/lib/offline/mutations.ts index 4d8d954..ffb5ebe 100644 --- a/src/lib/offline/mutations.ts +++ b/src/lib/offline/mutations.ts @@ -54,7 +54,7 @@ export async function offlineAddEntry( product: Product, grams: number ): Promise { - if (navigator.onLine) { + if (network.online) { await createEntry(date, mealId, product.id, grams); await queryClient.invalidateQueries({ queryKey: ['diary', date] }); return; @@ -99,7 +99,7 @@ export async function offlineEditEntry( const diary = queryClient.getQueryData(['diary', date]); const mealId = diary?.meals.find(m => m.entries.some(e => e.id === entryId))?.id; - if (navigator.onLine) { + if (network.online) { await updateEntry(date, mealId!, entryId, { grams: newGrams }); await queryClient.invalidateQueries({ queryKey: ['diary', date] }); return; @@ -150,7 +150,7 @@ export async function offlineAddMeal( date: string, name: string ): Promise { - if (navigator.onLine) { + if (network.online) { await createMeal(date, name || 'Meal'); await queryClient.invalidateQueries({ queryKey: ['diary', date] }); return; diff --git a/src/lib/offline/network.svelte.ts b/src/lib/offline/network.svelte.ts index e64a387..68bfc73 100644 --- a/src/lib/offline/network.svelte.ts +++ b/src/lib/offline/network.svelte.ts @@ -15,5 +15,6 @@ export const network = { incrementPending() { _pendingCount++; }, decrementPending() { _pendingCount = Math.max(0, _pendingCount - 1); }, setPendingCount(n: number) { _pendingCount = n; }, - setSyncing(v: boolean) { _syncing = v; } + setSyncing(v: boolean) { _syncing = v; }, + setOffline() { _online = false; } }; diff --git a/src/routes/(app)/diary/[date]/+page.svelte b/src/routes/(app)/diary/[date]/+page.svelte index 6c2cea2..ad64039 100644 --- a/src/routes/(app)/diary/[date]/+page.svelte +++ b/src/routes/(app)/diary/[date]/+page.svelte @@ -5,6 +5,7 @@ import { getCachedDiary, cacheDiary } from '$lib/offline/db'; import { addDays, formatDisplay, today } from '$lib/utils/date'; import { goto } from '$app/navigation'; + import { network } from '$lib/offline/network.svelte'; import { onMount } from 'svelte'; import MacroSummary from '$lib/components/diary/MacroSummary.svelte'; import MealCard from '$lib/components/diary/MealCard.svelte'; @@ -53,7 +54,7 @@ const diaryQuery = createQuery(() => ({ queryKey: ['diary', date], queryFn: async () => { - if (!navigator.onLine) { + if (!network.online) { // Prefer in-memory cache — it contains any optimistic updates const inMemory = queryClient.getQueryData(['diary', date]); if (inMemory) return inMemory; @@ -64,17 +65,27 @@ } // Online: fetch fresh data — this overwrites both TQ cache and IndexedDB, // replacing any temporary negative-ID entries from optimistic updates - const diary = await getDiary(date); - if (diary === null) { - if (date === today()) { - const created = await createDiary(date); - await cacheDiary(JSON.parse(JSON.stringify(created))); - return created; + try { + const diary = await getDiary(date); + if (diary === null) { + if (date === today()) { + const created = await createDiary(date); + await cacheDiary(JSON.parse(JSON.stringify(created))); + return created; + } + return null; } - return null; + await cacheDiary(JSON.parse(JSON.stringify(diary))); + return diary; + } catch { + // Network failed despite network.online being true — fall back to cache + network.setOffline(); + const inMemory = queryClient.getQueryData(['diary', date]); + if (inMemory) return inMemory; + const cached = await getCachedDiary(date); + if (cached) return cached; + throw new Error('Offline and no cached data'); } - await cacheDiary(JSON.parse(JSON.stringify(diary))); - return diary; } })); @@ -125,7 +136,7 @@ {:else if diaryQuery.isError}

Could not load diary

-

{diaryQuery.error.message}

+

{diaryQuery.error?.message ?? 'Unknown error'}

-
- {:else if diaryQuery.data} - {@const diary = diaryQuery.data} - -
- -
- -
- - -
- {#each diary.meals as meal (meal.id)} - - {/each} - - - -
-
- {/if} - - - - {#if diaryQuery.data != null} - - {/if} - - calendarOpen = false}> - - - - commandOpen = false} /> - + diff --git a/src/routes/(app)/diary/today/+page.ts b/src/routes/(app)/diary/today/+page.ts new file mode 100644 index 0000000..c7e1970 --- /dev/null +++ b/src/routes/(app)/diary/today/+page.ts @@ -0,0 +1,6 @@ +import { redirect } from '@sveltejs/kit'; +import { today } from '$lib/utils/date'; + +export function load() { + throw redirect(307, `/diary/${today()}`); +} diff --git a/vite.config.ts b/vite.config.ts index b1b29b6..694887f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -41,8 +41,20 @@ export default defineConfig({ }, workbox: { navigateFallback: '/index.html', - globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'], + globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2,json}'], + skipWaiting: true, + clientsClaim: true, + cleanupOutdatedCaches: true, runtimeCaching: [ + { + // SvelteKit immutable app chunks (hashed filenames — safe to cache forever) + urlPattern: ({ url }) => url.pathname.startsWith('/_app/'), + handler: 'CacheFirst', + options: { + cacheName: 'app-shell', + expiration: { maxEntries: 200, maxAgeSeconds: 60 * 60 * 24 * 365 } + } + }, { // Cache same-origin API responses (NetworkFirst: try network, fall back to cache) urlPattern: ({ url }) => url.pathname.startsWith('/api/'),