diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index 9925a77..434eaad 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -1,5 +1,7 @@ import { auth } from '$lib/auth/store.svelte'; import { goto } from '$app/navigation'; +import { enqueueMutation } from '$lib/offline/db'; +import { network } from '$lib/offline/network.svelte'; const BASE = '/api'; @@ -73,7 +75,17 @@ export async function apiGet(path: string): Promise { return res.json(); } +// Auth endpoints must never be queued (token ops need live responses) +function isAuthPath(path: string) { + return path.startsWith('/token/'); +} + export async function apiPost(path: string, body: unknown): Promise { + if (!navigator.onLine && !isAuthPath(path)) { + await enqueueMutation({ method: 'POST', url: path, body }); + network.incrementPending(); + return {} as T; + } const res = await apiFetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -87,6 +99,11 @@ 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(); + return {} as T; + } const res = await apiFetch(path, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, @@ -97,6 +114,11 @@ export async function apiPatch(path: string, body: unknown): Promise { } export async function apiDelete(path: string): Promise { + if (!navigator.onLine && !isAuthPath(path)) { + await enqueueMutation({ method: 'DELETE', url: path, body: undefined }); + network.incrementPending(); + return; + } const res = await apiFetch(path, { method: 'DELETE' }); if (!res.ok) throw new Error(`DELETE ${path} failed: ${res.status}`); } diff --git a/src/lib/offline/db.ts b/src/lib/offline/db.ts index 51c0825..138d11a 100644 --- a/src/lib/offline/db.ts +++ b/src/lib/offline/db.ts @@ -1,15 +1,14 @@ import { openDB, type IDBPDatabase } from 'idb'; -import type { Diary } from '$lib/types/api'; +import type { Diary, Product } from '$lib/types/api'; const DB_NAME = 'fooder'; -const DB_VERSION = 1; +const DB_VERSION = 2; export interface QueuedMutation { id?: number; method: 'POST' | 'PATCH' | 'DELETE'; url: string; body: unknown; - queryKeysToInvalidate: string[][]; createdAt: number; } @@ -18,18 +17,23 @@ let dbInstance: IDBPDatabase | null = null; export async function getDb() { if (dbInstance) return dbInstance; dbInstance = await openDB(DB_NAME, DB_VERSION, { - upgrade(db) { - if (!db.objectStoreNames.contains('diaries')) { + upgrade(db, oldVersion) { + if (oldVersion < 1) { db.createObjectStore('diaries', { keyPath: 'date' }); - } - if (!db.objectStoreNames.contains('mutation_queue')) { db.createObjectStore('mutation_queue', { keyPath: 'id', autoIncrement: true }); } + if (oldVersion < 2) { + if (!db.objectStoreNames.contains('products')) { + db.createObjectStore('products', { keyPath: 'id' }); + } + } } }); return dbInstance; } +// ── Diary cache ────────────────────────────────────────────────────────────── + export async function cacheDiary(diary: Diary): Promise { const db = await getDb(); await db.put('diaries', diary); @@ -39,3 +43,46 @@ export async function getCachedDiary(date: string): Promise { const db = await getDb(); return db.get('diaries', date); } + +// ── Product cache ───────────────────────────────────────────────────────────── + +export async function cacheProducts(products: Product[]): Promise { + if (products.length === 0) return; + const db = await getDb(); + const tx = db.transaction('products', 'readwrite'); + await Promise.all(products.map(p => tx.store.put(p))); + await tx.done; +} + +export async function searchCachedProducts(q: string): Promise { + const db = await getDb(); + const all = await db.getAll('products'); + if (!q.trim()) return all.slice(0, 30); + const lower = q.toLowerCase(); + return all + .filter(p => p.name.toLowerCase().includes(lower)) + .sort((a, b) => (b.usage_count_cached ?? 0) - (a.usage_count_cached ?? 0)) + .slice(0, 30); +} + +// ── Mutation queue ──────────────────────────────────────────────────────────── + +export async function enqueueMutation(mutation: Omit): Promise { + const db = await getDb(); + await db.add('mutation_queue', { ...mutation, createdAt: Date.now() }); +} + +export async function getMutationQueue(): Promise { + const db = await getDb(); + return db.getAll('mutation_queue'); +} + +export async function dequeueMutation(id: number): Promise { + const db = await getDb(); + await db.delete('mutation_queue', id); +} + +export async function getMutationQueueLength(): Promise { + const db = await getDb(); + return db.count('mutation_queue'); +} diff --git a/src/lib/offline/mutations.ts b/src/lib/offline/mutations.ts new file mode 100644 index 0000000..a47445c --- /dev/null +++ b/src/lib/offline/mutations.ts @@ -0,0 +1,177 @@ +import type { QueryClient } from '@tanstack/svelte-query'; +import type { Diary, Entry, Meal, Product } from '$lib/types/api'; +import { createEntry, updateEntry } from '$lib/api/entries'; +import { createMeal } from '$lib/api/meals'; +import { enqueueMutation } from './db'; +import { cacheDiary } from './db'; +import { network } from './network.svelte'; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function macrosFromProduct(product: Product, grams: number) { + const f = grams / 100; + return { + calories: product.calories * f, + protein: product.protein * f, + carb: product.carb * f, + fat: product.fat * f, + fiber: product.fiber * f, + }; +} + +function addMacros( + obj: T, + delta: { calories: number; protein: number; carb: number; fat: number; fiber: number } +): T { + return { + ...obj, + calories: obj.calories + delta.calories, + protein: obj.protein + delta.protein, + carb: obj.carb + delta.carb, + fat: obj.fat + delta.fat, + fiber: obj.fiber + delta.fiber, + }; +} + +// Persist optimistic diary to IndexedDB so it survives page reloads while offline. +// Uses JSON round-trip to strip Svelte 5 reactive proxies before structured clone. +// Non-throwing: in-memory TQ optimistic update works regardless. +async function persistOptimistic(queryClient: QueryClient, date: string) { + try { + const diary = queryClient.getQueryData(['diary', date]); + if (diary) await cacheDiary(JSON.parse(JSON.stringify(diary))); + } catch { + // Non-critical — in-memory optimistic update already applied + } +} + +// ── Add entry ───────────────────────────────────────────────────────────────── + +export async function offlineAddEntry( + queryClient: QueryClient, + date: string, + mealId: number, + product: Product, + grams: number +): Promise { + if (navigator.onLine) { + await createEntry(mealId, product.id, grams); + await queryClient.invalidateQueries({ queryKey: ['diary', date] }); + return; + } + + if (mealId < 0) { + throw new Error('Cannot add entry to an unsaved meal — please sync first.'); + } + + await enqueueMutation({ method: 'POST', url: '/entry', body: { meal_id: mealId, product_id: product.id, grams } }); + network.incrementPending(); + + const macros = macrosFromProduct(product, grams); + // Strip Svelte reactive proxy from product before storing/using in structured clone contexts + const plainProduct: Product = JSON.parse(JSON.stringify(product)); + const fakeEntry: Entry = { id: -Date.now(), grams, product: plainProduct, meal_id: mealId, ...macros }; + + queryClient.setQueryData(['diary', date], diary => { + if (!diary) return diary; + return addMacros({ + ...diary, + meals: diary.meals.map(meal => + meal.id !== mealId ? meal : addMacros({ + ...meal, + entries: [...meal.entries, fakeEntry], + }, macros) + ), + }, macros); + }); + + await persistOptimistic(queryClient, date); +} + +// ── Edit entry ──────────────────────────────────────────────────────────────── + +export async function offlineEditEntry( + queryClient: QueryClient, + date: string, + entryId: number, + newGrams: number +): Promise { + if (navigator.onLine) { + await updateEntry(entryId, { grams: newGrams }); + await queryClient.invalidateQueries({ queryKey: ['diary', date] }); + return; + } + + if (entryId < 0) { + throw new Error('Cannot edit an unsaved entry — please sync first.'); + } + + await enqueueMutation({ method: 'PATCH', url: `/entry/${entryId}`, body: { grams: newGrams } }); + network.incrementPending(); + + queryClient.setQueryData(['diary', date], diary => { + if (!diary) return diary; + let delta = { calories: 0, protein: 0, carb: 0, fat: 0, fiber: 0 }; + + const meals = diary.meals.map(meal => { + const idx = meal.entries.findIndex(e => e.id === entryId); + if (idx === -1) return meal; + + const old = meal.entries[idx]; + const newMacros = macrosFromProduct(old.product, newGrams); + const oldMacros = macrosFromProduct(old.product, old.grams); + delta = { + calories: newMacros.calories - oldMacros.calories, + protein: newMacros.protein - oldMacros.protein, + carb: newMacros.carb - oldMacros.carb, + fat: newMacros.fat - oldMacros.fat, + fiber: newMacros.fiber - oldMacros.fiber, + }; + + const updatedEntry: Entry = { ...old, grams: newGrams, ...newMacros }; + const entries = [...meal.entries]; + entries[idx] = updatedEntry; + return addMacros({ ...meal, entries }, delta); + }); + + return addMacros({ ...diary, meals }, delta); + }); + + await persistOptimistic(queryClient, date); +} + +// ── Add meal ────────────────────────────────────────────────────────────────── + +export async function offlineAddMeal( + queryClient: QueryClient, + date: string, + diaryId: number, + name: string +): Promise { + if (navigator.onLine) { + await createMeal(diaryId, name || undefined); + await queryClient.invalidateQueries({ queryKey: ['diary', date] }); + return; + } + + await enqueueMutation({ method: 'POST', url: '/meal', body: { diary_id: diaryId, name: name || undefined } }); + network.incrementPending(); + + const diary = queryClient.getQueryData(['diary', date]); + const order = (diary?.meals.length ?? 0) + 1; + const fakeMeal: Meal = { + id: -Date.now(), + name: name || `Meal ${order}`, + order, + diary_id: diaryId, + entries: [], + calories: 0, protein: 0, carb: 0, fat: 0, fiber: 0, + }; + + queryClient.setQueryData(['diary', date], d => { + if (!d) return d; + return { ...d, meals: [...d.meals, fakeMeal] }; + }); + + await persistOptimistic(queryClient, date); +} diff --git a/src/lib/offline/network.svelte.ts b/src/lib/offline/network.svelte.ts new file mode 100644 index 0000000..e64a387 --- /dev/null +++ b/src/lib/offline/network.svelte.ts @@ -0,0 +1,19 @@ +let _online = $state(typeof navigator !== 'undefined' ? navigator.onLine : true); +let _pendingCount = $state(0); +let _syncing = $state(false); + +if (typeof window !== 'undefined') { + window.addEventListener('online', () => { _online = true; }); + window.addEventListener('offline', () => { _online = false; }); +} + +export const network = { + get online() { return _online; }, + get pendingCount() { return _pendingCount; }, + get syncing() { return _syncing; }, + + incrementPending() { _pendingCount++; }, + decrementPending() { _pendingCount = Math.max(0, _pendingCount - 1); }, + setPendingCount(n: number) { _pendingCount = n; }, + setSyncing(v: boolean) { _syncing = v; } +}; diff --git a/src/lib/offline/sync.ts b/src/lib/offline/sync.ts new file mode 100644 index 0000000..6dbfcee --- /dev/null +++ b/src/lib/offline/sync.ts @@ -0,0 +1,40 @@ +import { getMutationQueue, dequeueMutation } from './db'; +import { apiFetch } from '$lib/api/client'; + +/** + * Replays all queued offline mutations in order. + * Returns the number of successfully synced mutations. + * Stops at the first failure to preserve ordering. + */ +export async function syncOfflineQueue(): Promise { + const queue = await getMutationQueue(); + let synced = 0; + + for (const mutation of queue) { + try { + const headers: Record = {}; + if (mutation.body !== undefined) { + headers['Content-Type'] = 'application/json'; + } + + const res = await apiFetch(mutation.url, { + method: mutation.method, + headers, + body: mutation.body !== undefined ? JSON.stringify(mutation.body) : undefined + }); + + if (res.ok) { + await dequeueMutation(mutation.id!); + synced++; + } else { + // Non-retryable failure (e.g. 400 validation error) — drop it to unblock the queue + await dequeueMutation(mutation.id!); + } + } catch { + // Network error mid-sync — stop here, retry next time online + break; + } + } + + return synced; +} diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte index 9730b9d..6fe0d07 100644 --- a/src/routes/(app)/+layout.svelte +++ b/src/routes/(app)/+layout.svelte @@ -6,14 +6,39 @@ import { today } from '$lib/utils/date'; import { page } from '$app/state'; import { onMount } from 'svelte'; + import { network } from '$lib/offline/network.svelte'; + import { syncOfflineQueue } from '$lib/offline/sync'; + import { getMutationQueueLength } from '$lib/offline/db'; let { children } = $props(); const queryClient = useQueryClient(); - onMount(() => { + onMount(async () => { if (!auth.isAuthenticated) goto('/login'); + + // Restore pending count from IndexedDB in case of page reload while offline + const queued = await getMutationQueueLength(); + network.setPendingCount(queued); + + // Sync on reconnect + window.addEventListener('online', handleReconnect); + return () => window.removeEventListener('online', handleReconnect); }); + async function handleReconnect() { + if (network.syncing) return; // prevent concurrent sync on rapid reconnects + if (network.pendingCount === 0) return; + network.setSyncing(true); + try { + await syncOfflineQueue(); + network.setPendingCount(0); + // Refetch everything so optimistic data is replaced by server truth + await queryClient.invalidateQueries(); + } finally { + network.setSyncing(false); + } + } + function handleLogout() { logout(); queryClient.clear(); @@ -26,6 +51,27 @@ {#if auth.isAuthenticated}
+ + {#if !network.online || network.syncing} +
+ {#if network.syncing} + + + + Syncing changes… + {:else} + + + + Offline{network.pendingCount > 0 ? ` · ${network.pendingCount} change${network.pendingCount === 1 ? '' : 's'} pending sync` : ''} + {/if} +
+ {/if} +
{#if scanError} @@ -142,11 +158,16 @@ {:else if productsQuery.data?.products.length === 0}
-

No products found

-

Try a different name or

- - Create "{q || 'new product'}" - + {#if !network.online} +

No cached products match "{q}"

+

Connect to internet to search all products

+ {:else} +

No products found

+

Try a different name or

+ + Create "{q || 'new product'}" + + {/if}
{:else}
    @@ -176,7 +197,7 @@ selectedProduct = null} + onclose={() => { selectedProduct = null; error = ''; }} title={selectedProduct?.name ?? ''} > {#if selectedProduct} @@ -197,7 +218,7 @@ {/if} -
    +
    + {#if error} +

    {error}

    + {/if} + {/if} diff --git a/src/routes/(app)/diary/[date]/add-meal/+page.svelte b/src/routes/(app)/diary/[date]/add-meal/+page.svelte index d43af37..578fe3d 100644 --- a/src/routes/(app)/diary/[date]/add-meal/+page.svelte +++ b/src/routes/(app)/diary/[date]/add-meal/+page.svelte @@ -2,7 +2,9 @@ import { page } from '$app/state'; import { goto } from '$app/navigation'; import { createQuery, useQueryClient } from '@tanstack/svelte-query'; - import { createMeal, createMealFromPreset } from '$lib/api/meals'; + import { createMealFromPreset } from '$lib/api/meals'; + import { offlineAddMeal } from '$lib/offline/mutations'; + import { network } from '$lib/offline/network.svelte'; import { listPresets } from '$lib/api/presets'; import type { Preset } from '$lib/types/api'; import TopBar from '$lib/components/ui/TopBar.svelte'; @@ -36,8 +38,7 @@ e.preventDefault(); submitting = true; try { - await createMeal(diaryId, mealName || undefined); - await queryClient.invalidateQueries({ queryKey: ['diary', date] }); + await offlineAddMeal(queryClient, date, diaryId, mealName); goto(`/diary/${date}`); } finally { submitting = false; @@ -63,10 +64,11 @@
    - {#each [{ id: 'new', label: 'New meal' }, { id: 'preset', label: 'From preset' }] as t} + {#each [{ id: 'new', label: 'New meal' }, { id: 'preset', label: 'From preset', offlineDisabled: true }] as t} diff --git a/src/routes/(app)/diary/[date]/edit-entry/[entry_id]/+page.svelte b/src/routes/(app)/diary/[date]/edit-entry/[entry_id]/+page.svelte index e56354a..6ceea72 100644 --- a/src/routes/(app)/diary/[date]/edit-entry/[entry_id]/+page.svelte +++ b/src/routes/(app)/diary/[date]/edit-entry/[entry_id]/+page.svelte @@ -2,7 +2,9 @@ import { page } from '$app/state'; import { goto } from '$app/navigation'; import { useQueryClient } from '@tanstack/svelte-query'; - import { updateEntry, deleteEntry } from '$lib/api/entries'; + import { deleteEntry } from '$lib/api/entries'; + import { offlineEditEntry } from '$lib/offline/mutations'; + import { network } from '$lib/offline/network.svelte'; import TopBar from '$lib/components/ui/TopBar.svelte'; const date = $derived(page.params.date); @@ -34,8 +36,7 @@ if (!entry) return; saving = true; try { - await updateEntry(entryId, { grams }); - await queryClient.invalidateQueries({ queryKey: ['diary', date] }); + await offlineEditEntry(queryClient, date, entryId, grams); goto(`/diary/${date}`); } finally { saving = false; @@ -132,7 +133,7 @@ disabled={saving || grams < 1 || grams === entry.grams} class="w-full bg-green-600 hover:bg-green-500 disabled:opacity-40 rounded-xl py-3.5 font-semibold transition-colors" > - {saving ? 'Saving…' : 'Save changes'} + {saving ? 'Saving…' : network.online ? 'Save changes' : 'Save changes (offline)'}
    {/if}