offline
This commit is contained in:
parent
970f302b9f
commit
189b4dc967
10 changed files with 434 additions and 49 deletions
|
|
@ -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<T>(path: string): Promise<T> {
|
|||
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> {
|
||||
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<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, {
|
||||
method: 'PATCH',
|
||||
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> {
|
||||
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}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
const db = await getDb();
|
||||
await db.put('diaries', diary);
|
||||
|
|
@ -39,3 +43,46 @@ export async function getCachedDiary(date: string): Promise<Diary | undefined> {
|
|||
const db = await getDb();
|
||||
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');
|
||||
}
|
||||
|
|
|
|||
177
src/lib/offline/mutations.ts
Normal file
177
src/lib/offline/mutations.ts
Normal 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);
|
||||
}
|
||||
19
src/lib/offline/network.svelte.ts
Normal file
19
src/lib/offline/network.svelte.ts
Normal 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
40
src/lib/offline/sync.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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}
|
||||
<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) -->
|
||||
<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">
|
||||
|
|
|
|||
|
|
@ -20,12 +20,18 @@
|
|||
queryKey: ['diary', date],
|
||||
queryFn: async () => {
|
||||
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);
|
||||
if (cached) return cached;
|
||||
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);
|
||||
await cacheDiary(diary);
|
||||
await cacheDiary(JSON.parse(JSON.stringify(diary)));
|
||||
return diary;
|
||||
}
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@
|
|||
import { goto } from '$app/navigation';
|
||||
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
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 TopBar from '$lib/components/ui/TopBar.svelte';
|
||||
import Sheet from '$lib/components/ui/Sheet.svelte';
|
||||
|
|
@ -21,6 +23,7 @@
|
|||
let selectedProduct = $state<Product | null>(null);
|
||||
let grams = $state(100);
|
||||
let submitting = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
let scannerOpen = $state(false);
|
||||
let scanLoading = $state(false);
|
||||
|
|
@ -34,13 +37,23 @@
|
|||
|
||||
const productsQuery = createQuery(() => ({
|
||||
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
|
||||
}));
|
||||
|
||||
function selectProduct(product: Product) {
|
||||
selectedProduct = product;
|
||||
grams = 100;
|
||||
error = '';
|
||||
}
|
||||
|
||||
async function handleBarcodeDetected(barcode: string) {
|
||||
|
|
@ -64,11 +77,12 @@
|
|||
async function handleAddEntry() {
|
||||
if (!selectedProduct || !mealId) return;
|
||||
submitting = true;
|
||||
error = '';
|
||||
try {
|
||||
await createEntry(mealId, selectedProduct.id, grams);
|
||||
await queryClient.invalidateQueries({ queryKey: ['diary', date] });
|
||||
await offlineAddEntry(queryClient, date, mealId, selectedProduct, grams);
|
||||
goto(`/diary/${date}`);
|
||||
} finally {
|
||||
} catch (e: any) {
|
||||
error = e.message ?? 'Failed to add entry';
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
|
@ -108,23 +122,25 @@
|
|||
/>
|
||||
</div>
|
||||
|
||||
<!-- Barcode scan button -->
|
||||
<button
|
||||
onclick={() => { scanError = null; scannerOpen = true; }}
|
||||
disabled={scanLoading}
|
||||
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">
|
||||
<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>
|
||||
{:else}
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
{/if}
|
||||
</button>
|
||||
<!-- Barcode scan button (online only) -->
|
||||
{#if network.online}
|
||||
<button
|
||||
onclick={() => { scanError = null; scannerOpen = true; }}
|
||||
disabled={scanLoading}
|
||||
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">
|
||||
<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>
|
||||
{:else}
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if scanError}
|
||||
|
|
@ -142,11 +158,16 @@
|
|||
</div>
|
||||
{:else if productsQuery.data?.products.length === 0}
|
||||
<div class="text-center text-zinc-500 mt-16 px-6">
|
||||
<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 !network.online}
|
||||
<p class="text-base">No cached products match "{q}"</p>
|
||||
<p class="text-sm mt-1">Connect to internet to search all products</p>
|
||||
{:else}
|
||||
<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>
|
||||
{:else}
|
||||
<ul class="divide-y divide-zinc-800/50">
|
||||
|
|
@ -176,7 +197,7 @@
|
|||
<!-- Grams sheet -->
|
||||
<Sheet
|
||||
open={selectedProduct !== null}
|
||||
onclose={() => selectedProduct = null}
|
||||
onclose={() => { selectedProduct = null; error = ''; }}
|
||||
title={selectedProduct?.name ?? ''}
|
||||
>
|
||||
{#if selectedProduct}
|
||||
|
|
@ -197,7 +218,7 @@
|
|||
{/if}
|
||||
|
||||
<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
|
||||
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"
|
||||
|
|
@ -215,12 +236,16 @@
|
|||
>+</button>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p class="text-red-400 text-sm mb-3">{error}</p>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
onclick={handleAddEntry}
|
||||
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"
|
||||
>
|
||||
{submitting ? 'Adding…' : 'Add to meal'}
|
||||
{submitting ? 'Adding…' : network.online ? 'Add to meal' : 'Add to meal (offline)'}
|
||||
</button>
|
||||
{/if}
|
||||
</Sheet>
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
|
||||
<!-- Tabs -->
|
||||
<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
|
||||
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-transparent text-zinc-500 hover:text-zinc-300'}"
|
||||
>
|
||||
|
|
@ -94,7 +96,7 @@
|
|||
disabled={submitting}
|
||||
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>
|
||||
</form>
|
||||
|
||||
|
|
|
|||
|
|
@ -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)'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
Loading…
Reference in a new issue