This commit is contained in:
Piotr Domański 2026-04-02 09:37:44 +02:00
parent 970f302b9f
commit 189b4dc967
10 changed files with 434 additions and 49 deletions

View file

@ -1,5 +1,7 @@
import { auth } from '$lib/auth/store.svelte'; import { auth } from '$lib/auth/store.svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { enqueueMutation } from '$lib/offline/db';
import { network } from '$lib/offline/network.svelte';
const BASE = '/api'; const BASE = '/api';
@ -73,7 +75,17 @@ export async function apiGet<T>(path: string): Promise<T> {
return res.json(); 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<T>(path: string, body: unknown): Promise<T> { export async function apiPost<T>(path: string, body: unknown): Promise<T> {
if (!navigator.onLine && !isAuthPath(path)) {
await enqueueMutation({ method: 'POST', url: path, body });
network.incrementPending();
return {} as T;
}
const res = await apiFetch(path, { const res = await apiFetch(path, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@ -87,6 +99,11 @@ export async function apiPost<T>(path: string, body: unknown): Promise<T> {
} }
export async function apiPatch<T>(path: string, body: unknown): Promise<T> { export async function apiPatch<T>(path: string, body: unknown): Promise<T> {
if (!navigator.onLine && !isAuthPath(path)) {
await enqueueMutation({ method: 'PATCH', url: path, body });
network.incrementPending();
return {} as T;
}
const res = await apiFetch(path, { const res = await apiFetch(path, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@ -97,6 +114,11 @@ export async function apiPatch<T>(path: string, body: unknown): Promise<T> {
} }
export async function apiDelete(path: string): Promise<void> { export async function apiDelete(path: string): Promise<void> {
if (!navigator.onLine && !isAuthPath(path)) {
await enqueueMutation({ method: 'DELETE', url: path, body: undefined });
network.incrementPending();
return;
}
const res = await apiFetch(path, { method: 'DELETE' }); const res = await apiFetch(path, { method: 'DELETE' });
if (!res.ok) throw new Error(`DELETE ${path} failed: ${res.status}`); if (!res.ok) throw new Error(`DELETE ${path} failed: ${res.status}`);
} }

View file

@ -1,15 +1,14 @@
import { openDB, type IDBPDatabase } from 'idb'; 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_NAME = 'fooder';
const DB_VERSION = 1; const DB_VERSION = 2;
export interface QueuedMutation { export interface QueuedMutation {
id?: number; id?: number;
method: 'POST' | 'PATCH' | 'DELETE'; method: 'POST' | 'PATCH' | 'DELETE';
url: string; url: string;
body: unknown; body: unknown;
queryKeysToInvalidate: string[][];
createdAt: number; createdAt: number;
} }
@ -18,18 +17,23 @@ let dbInstance: IDBPDatabase | null = null;
export async function getDb() { export async function getDb() {
if (dbInstance) return dbInstance; if (dbInstance) return dbInstance;
dbInstance = await openDB(DB_NAME, DB_VERSION, { dbInstance = await openDB(DB_NAME, DB_VERSION, {
upgrade(db) { upgrade(db, oldVersion) {
if (!db.objectStoreNames.contains('diaries')) { if (oldVersion < 1) {
db.createObjectStore('diaries', { keyPath: 'date' }); db.createObjectStore('diaries', { keyPath: 'date' });
}
if (!db.objectStoreNames.contains('mutation_queue')) {
db.createObjectStore('mutation_queue', { keyPath: 'id', autoIncrement: true }); db.createObjectStore('mutation_queue', { keyPath: 'id', autoIncrement: true });
} }
if (oldVersion < 2) {
if (!db.objectStoreNames.contains('products')) {
db.createObjectStore('products', { keyPath: 'id' });
}
}
} }
}); });
return dbInstance; return dbInstance;
} }
// ── Diary cache ──────────────────────────────────────────────────────────────
export async function cacheDiary(diary: Diary): Promise<void> { export async function cacheDiary(diary: Diary): Promise<void> {
const db = await getDb(); const db = await getDb();
await db.put('diaries', diary); await db.put('diaries', diary);
@ -39,3 +43,46 @@ export async function getCachedDiary(date: string): Promise<Diary | undefined> {
const db = await getDb(); const db = await getDb();
return db.get('diaries', date); return db.get('diaries', date);
} }
// ── Product cache ─────────────────────────────────────────────────────────────
export async function cacheProducts(products: Product[]): Promise<void> {
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<Product[]> {
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<QueuedMutation, 'id' | 'createdAt'>): Promise<void> {
const db = await getDb();
await db.add('mutation_queue', { ...mutation, createdAt: Date.now() });
}
export async function getMutationQueue(): Promise<QueuedMutation[]> {
const db = await getDb();
return db.getAll('mutation_queue');
}
export async function dequeueMutation(id: number): Promise<void> {
const db = await getDb();
await db.delete('mutation_queue', id);
}
export async function getMutationQueueLength(): Promise<number> {
const db = await getDb();
return db.count('mutation_queue');
}

View file

@ -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<T extends { calories: number; protein: number; carb: number; fat: number; fiber: number }>(
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>(['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<void> {
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>(['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<void> {
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>(['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<void> {
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>(['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>(['diary', date], d => {
if (!d) return d;
return { ...d, meals: [...d.meals, fakeMeal] };
});
await persistOptimistic(queryClient, date);
}

View file

@ -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; }
};

40
src/lib/offline/sync.ts Normal file
View file

@ -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<number> {
const queue = await getMutationQueue();
let synced = 0;
for (const mutation of queue) {
try {
const headers: Record<string, string> = {};
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;
}

View file

@ -6,14 +6,39 @@
import { today } from '$lib/utils/date'; import { today } from '$lib/utils/date';
import { page } from '$app/state'; import { page } from '$app/state';
import { onMount } from 'svelte'; 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(); let { children } = $props();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
onMount(() => { onMount(async () => {
if (!auth.isAuthenticated) goto('/login'); 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() { function handleLogout() {
logout(); logout();
queryClient.clear(); queryClient.clear();
@ -26,6 +51,27 @@
{#if auth.isAuthenticated} {#if auth.isAuthenticated}
<div class="lg:flex min-h-screen"> <div class="lg:flex min-h-screen">
<!-- Offline / syncing banner -->
{#if !network.online || network.syncing}
<div
class="fixed top-0 inset-x-0 z-50 flex items-center justify-center gap-2 px-4 py-2 text-xs font-medium
{network.syncing ? 'bg-blue-600' : 'bg-zinc-700'}"
style="padding-top: calc(0.5rem + var(--safe-top))"
>
{#if network.syncing}
<svg class="w-3.5 h-3.5 animate-spin shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Syncing changes…
{:else}
<svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 5.636a9 9 0 010 12.728M15.536 8.464a5 5 0 010 7.072M3 3l18 18" />
</svg>
Offline{network.pendingCount > 0 ? ` · ${network.pendingCount} change${network.pendingCount === 1 ? '' : 's'} pending sync` : ''}
{/if}
</div>
{/if}
<!-- Sidebar (desktop only) --> <!-- Sidebar (desktop only) -->
<aside class="hidden lg:flex flex-col w-52 shrink-0 fixed inset-y-0 border-r border-zinc-800 bg-zinc-950 px-3 py-6 z-20"> <aside class="hidden lg:flex flex-col w-52 shrink-0 fixed inset-y-0 border-r border-zinc-800 bg-zinc-950 px-3 py-6 z-20">
<div class="px-2 mb-8"> <div class="px-2 mb-8">

View file

@ -20,12 +20,18 @@
queryKey: ['diary', date], queryKey: ['diary', date],
queryFn: async () => { queryFn: async () => {
if (!navigator.onLine) { if (!navigator.onLine) {
// Prefer in-memory cache — it contains any optimistic updates
const inMemory = queryClient.getQueryData<import('$lib/types/api').Diary>(['diary', date]);
if (inMemory) return inMemory;
// Fall back to IndexedDB (survives page reload)
const cached = await getCachedDiary(date); const cached = await getCachedDiary(date);
if (cached) return cached; if (cached) return cached;
throw new Error('Offline and no cached data'); throw new Error('Offline and no cached data');
} }
// 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); const diary = await getDiary(date);
await cacheDiary(diary); await cacheDiary(JSON.parse(JSON.stringify(diary)));
return diary; return diary;
} }
})); }));

View file

@ -3,7 +3,9 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { createQuery, useQueryClient } from '@tanstack/svelte-query'; import { createQuery, useQueryClient } from '@tanstack/svelte-query';
import { listProducts, getProductByBarcode } from '$lib/api/products'; import { listProducts, getProductByBarcode } from '$lib/api/products';
import { createEntry } from '$lib/api/entries'; import { cacheProducts, searchCachedProducts } from '$lib/offline/db';
import { offlineAddEntry } from '$lib/offline/mutations';
import { network } from '$lib/offline/network.svelte';
import type { Product } from '$lib/types/api'; import type { Product } from '$lib/types/api';
import TopBar from '$lib/components/ui/TopBar.svelte'; import TopBar from '$lib/components/ui/TopBar.svelte';
import Sheet from '$lib/components/ui/Sheet.svelte'; import Sheet from '$lib/components/ui/Sheet.svelte';
@ -21,6 +23,7 @@
let selectedProduct = $state<Product | null>(null); let selectedProduct = $state<Product | null>(null);
let grams = $state(100); let grams = $state(100);
let submitting = $state(false); let submitting = $state(false);
let error = $state('');
let scannerOpen = $state(false); let scannerOpen = $state(false);
let scanLoading = $state(false); let scanLoading = $state(false);
@ -34,13 +37,23 @@
const productsQuery = createQuery(() => ({ const productsQuery = createQuery(() => ({
queryKey: ['products', debouncedQ], queryKey: ['products', debouncedQ],
queryFn: () => listProducts(debouncedQ, 30), queryFn: async () => {
if (!network.online) {
const products = await searchCachedProducts(debouncedQ);
return { products };
}
const result = await listProducts(debouncedQ, 30);
// Cache for offline use — fire and forget
cacheProducts(result.products);
return result;
},
staleTime: 0 staleTime: 0
})); }));
function selectProduct(product: Product) { function selectProduct(product: Product) {
selectedProduct = product; selectedProduct = product;
grams = 100; grams = 100;
error = '';
} }
async function handleBarcodeDetected(barcode: string) { async function handleBarcodeDetected(barcode: string) {
@ -64,11 +77,12 @@
async function handleAddEntry() { async function handleAddEntry() {
if (!selectedProduct || !mealId) return; if (!selectedProduct || !mealId) return;
submitting = true; submitting = true;
error = '';
try { try {
await createEntry(mealId, selectedProduct.id, grams); await offlineAddEntry(queryClient, date, mealId, selectedProduct, grams);
await queryClient.invalidateQueries({ queryKey: ['diary', date] });
goto(`/diary/${date}`); goto(`/diary/${date}`);
} finally { } catch (e: any) {
error = e.message ?? 'Failed to add entry';
submitting = false; submitting = false;
} }
} }
@ -108,23 +122,25 @@
/> />
</div> </div>
<!-- Barcode scan button --> <!-- Barcode scan button (online only) -->
<button {#if network.online}
onclick={() => { scanError = null; scannerOpen = true; }} <button
disabled={scanLoading} onclick={() => { scanError = null; scannerOpen = true; }}
class="w-11 h-11 flex items-center justify-center rounded-xl bg-zinc-900 border border-zinc-700 text-zinc-400 hover:text-green-400 hover:border-green-500 disabled:opacity-50 transition-colors shrink-0" disabled={scanLoading}
aria-label="Scan barcode" class="w-11 h-11 flex items-center justify-center rounded-xl bg-zinc-900 border border-zinc-700 text-zinc-400 hover:text-green-400 hover:border-green-500 disabled:opacity-50 transition-colors shrink-0"
> aria-label="Scan barcode"
{#if scanLoading} >
<svg class="w-5 h-5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24"> {#if scanLoading}
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /> <svg class="w-5 h-5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
</svg> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
{:else} </svg>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> {:else}
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v1m0 14v1M4 12h1m14 0h1M6.343 6.343l.707.707m9.9 9.9.707.707M6.343 17.657l.707-.707m9.9-9.9.707-.707M12 8a4 4 0 100 8 4 4 0 000-8z" /> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
</svg> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v1m0 14v1M4 12h1m14 0h1M6.343 6.343l.707.707m9.9 9.9.707.707M6.343 17.657l.707-.707m9.9-9.9.707-.707M12 8a4 4 0 100 8 4 4 0 000-8z" />
{/if} </svg>
</button> {/if}
</button>
{/if}
</div> </div>
{#if scanError} {#if scanError}
@ -142,11 +158,16 @@
</div> </div>
{:else if productsQuery.data?.products.length === 0} {:else if productsQuery.data?.products.length === 0}
<div class="text-center text-zinc-500 mt-16 px-6"> <div class="text-center text-zinc-500 mt-16 px-6">
<p class="text-base">No products found</p> {#if !network.online}
<p class="text-sm mt-1">Try a different name or</p> <p class="text-base">No cached products match "{q}"</p>
<a href="/products/new?name={encodeURIComponent(q)}" class="mt-3 inline-block text-green-500 text-sm"> <p class="text-sm mt-1">Connect to internet to search all products</p>
Create "{q || 'new product'}" {:else}
</a> <p class="text-base">No products found</p>
<p class="text-sm mt-1">Try a different name or</p>
<a href="/products/new?name={encodeURIComponent(q)}" class="mt-3 inline-block text-green-500 text-sm">
Create "{q || 'new product'}"
</a>
{/if}
</div> </div>
{:else} {:else}
<ul class="divide-y divide-zinc-800/50"> <ul class="divide-y divide-zinc-800/50">
@ -176,7 +197,7 @@
<!-- Grams sheet --> <!-- Grams sheet -->
<Sheet <Sheet
open={selectedProduct !== null} open={selectedProduct !== null}
onclose={() => selectedProduct = null} onclose={() => { selectedProduct = null; error = ''; }}
title={selectedProduct?.name ?? ''} title={selectedProduct?.name ?? ''}
> >
{#if selectedProduct} {#if selectedProduct}
@ -197,7 +218,7 @@
{/if} {/if}
<label class="block text-sm text-zinc-400 mb-2">Grams</label> <label class="block text-sm text-zinc-400 mb-2">Grams</label>
<div class="flex items-center gap-3 mb-5"> <div class="flex items-center gap-3 mb-4">
<button <button
onclick={() => grams = Math.max(1, grams - 10)} onclick={() => grams = Math.max(1, grams - 10)}
class="w-11 h-11 rounded-xl bg-zinc-800 hover:bg-zinc-700 transition-colors text-lg font-medium flex items-center justify-center" class="w-11 h-11 rounded-xl bg-zinc-800 hover:bg-zinc-700 transition-colors text-lg font-medium flex items-center justify-center"
@ -215,12 +236,16 @@
>+</button> >+</button>
</div> </div>
{#if error}
<p class="text-red-400 text-sm mb-3">{error}</p>
{/if}
<button <button
onclick={handleAddEntry} onclick={handleAddEntry}
disabled={submitting || grams < 1} disabled={submitting || grams < 1}
class="w-full bg-green-600 hover:bg-green-500 disabled:opacity-50 rounded-xl py-3 font-semibold transition-colors" class="w-full bg-green-600 hover:bg-green-500 disabled:opacity-50 rounded-xl py-3 font-semibold transition-colors"
> >
{submitting ? 'Adding…' : 'Add to meal'} {submitting ? 'Adding…' : network.online ? 'Add to meal' : 'Add to meal (offline)'}
</button> </button>
{/if} {/if}
</Sheet> </Sheet>

View file

@ -2,7 +2,9 @@
import { page } from '$app/state'; import { page } from '$app/state';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { createQuery, useQueryClient } from '@tanstack/svelte-query'; 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 { listPresets } from '$lib/api/presets';
import type { Preset } from '$lib/types/api'; import type { Preset } from '$lib/types/api';
import TopBar from '$lib/components/ui/TopBar.svelte'; import TopBar from '$lib/components/ui/TopBar.svelte';
@ -36,8 +38,7 @@
e.preventDefault(); e.preventDefault();
submitting = true; submitting = true;
try { try {
await createMeal(diaryId, mealName || undefined); await offlineAddMeal(queryClient, date, diaryId, mealName);
await queryClient.invalidateQueries({ queryKey: ['diary', date] });
goto(`/diary/${date}`); goto(`/diary/${date}`);
} finally { } finally {
submitting = false; submitting = false;
@ -63,10 +64,11 @@
<!-- Tabs --> <!-- Tabs -->
<div class="flex border-b border-zinc-800 bg-zinc-950"> <div class="flex border-b border-zinc-800 bg-zinc-950">
{#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}
<button <button
onclick={() => tab = t.id as Tab} onclick={() => tab = t.id as Tab}
class="flex-1 py-3 text-sm font-medium transition-colors border-b-2 {tab === t.id disabled={t.offlineDisabled && !network.online}
class="flex-1 py-3 text-sm font-medium transition-colors border-b-2 disabled:opacity-40 {tab === t.id
? 'border-green-500 text-green-400' ? 'border-green-500 text-green-400'
: 'border-transparent text-zinc-500 hover:text-zinc-300'}" : 'border-transparent text-zinc-500 hover:text-zinc-300'}"
> >
@ -94,7 +96,7 @@
disabled={submitting} disabled={submitting}
class="w-full bg-green-600 hover:bg-green-500 disabled:opacity-50 rounded-xl py-3 font-semibold transition-colors" class="w-full bg-green-600 hover:bg-green-500 disabled:opacity-50 rounded-xl py-3 font-semibold transition-colors"
> >
{submitting ? 'Creating…' : 'Create meal'} {submitting ? 'Creating…' : network.online ? 'Create meal' : 'Create meal (offline)'}
</button> </button>
</form> </form>

View file

@ -2,7 +2,9 @@
import { page } from '$app/state'; import { page } from '$app/state';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { useQueryClient } from '@tanstack/svelte-query'; 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'; import TopBar from '$lib/components/ui/TopBar.svelte';
const date = $derived(page.params.date); const date = $derived(page.params.date);
@ -34,8 +36,7 @@
if (!entry) return; if (!entry) return;
saving = true; saving = true;
try { try {
await updateEntry(entryId, { grams }); await offlineEditEntry(queryClient, date, entryId, grams);
await queryClient.invalidateQueries({ queryKey: ['diary', date] });
goto(`/diary/${date}`); goto(`/diary/${date}`);
} finally { } finally {
saving = false; saving = false;
@ -132,7 +133,7 @@
disabled={saving || grams < 1 || grams === entry.grams} 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" 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)'}
</button> </button>
</div> </div>
{/if} {/if}