From eb54fa59d9b31f2a61934148bdb986952e55199a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Doma=C5=84ski?= Date: Thu, 16 Apr 2026 22:11:34 +0200 Subject: [PATCH] [refactor] --- src/lib/api/client.ts | 22 ++- src/lib/components/diary/MacroSummary.svelte | 34 ++--- src/lib/components/diary/MealCard.svelte | 32 ++--- src/lib/components/ui/Button.svelte | 25 ++++ src/lib/components/ui/GramsStepper.svelte | 69 +++++++++ src/lib/components/ui/Input.svelte | 30 ++++ src/lib/components/ui/Sheet.svelte | 4 +- src/lib/offline/db.ts | 29 ++-- src/lib/offline/sync.ts | 1 + .../(app)/diary/[date]/add-entry/+page.svelte | 71 +++------- .../(app)/diary/[date]/add-meal/+page.svelte | 39 ++--- .../[date]/edit-entry/[entry_id]/+page.svelte | 46 ++---- src/routes/(app)/presets/+page.svelte | 134 ++++-------------- src/routes/(app)/products/new/+page.svelte | 28 ++-- src/routes/(app)/settings/+page.svelte | 28 ++-- src/routes/(auth)/login/+page.svelte | 16 +-- src/routes/(auth)/register/+page.svelte | 23 ++- 17 files changed, 315 insertions(+), 316 deletions(-) create mode 100644 src/lib/components/ui/Button.svelte create mode 100644 src/lib/components/ui/GramsStepper.svelte create mode 100644 src/lib/components/ui/Input.svelte diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index 4dc8b5f..29456be 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -5,6 +5,26 @@ import { network } from '$lib/offline/network.svelte'; const BASE = '/api'; +export class ApiError extends Error { + status: number; + detail: unknown; + constructor(message: string, status: number, detail: unknown) { + super(message); + this.name = 'ApiError'; + this.status = status; + this.detail = detail; + } +} + +export function extractErrorMessage(err: unknown, fallback = 'Something went wrong'): string { + if (err instanceof ApiError) { + const d = err.detail as Record | null; + if (typeof d?.detail === 'string') return d.detail; + } + if (err instanceof Error) return err.message; + return fallback; +} + let isRefreshing = false; let refreshPromise: Promise | null = null; @@ -121,7 +141,7 @@ export async function apiPost(path: string, body: unknown): Promise { return {} as T; } const err = await res.json().catch(() => ({})); - throw Object.assign(new Error(`POST ${path} failed: ${res.status}`), { status: res.status, detail: err }); + throw new ApiError(`POST ${path} failed: ${res.status}`, res.status, err); } return res.json(); } diff --git a/src/lib/components/diary/MacroSummary.svelte b/src/lib/components/diary/MacroSummary.svelte index 127bf0d..cb116fe 100644 --- a/src/lib/components/diary/MacroSummary.svelte +++ b/src/lib/components/diary/MacroSummary.svelte @@ -5,6 +5,8 @@ import { updateUserSettings } from "$lib/api/settings"; import { useQueryClient } from "@tanstack/svelte-query"; import Sheet from "$lib/components/ui/Sheet.svelte"; + import Button from "$lib/components/ui/Button.svelte"; + import Input from "$lib/components/ui/Input.svelte"; interface Props { diary: Diary; @@ -192,16 +194,8 @@ well?

- - + +
@@ -220,16 +214,17 @@ > {/if} - { - const v = e.currentTarget.value; + const v = (e.currentTarget as HTMLInputElement).value; form.calories_goal = v === "" ? null : Number(v); }} - class="w-full bg-zinc-800 border border-zinc-700 rounded-xl px-4 py-2.5 text-zinc-100 focus:outline-none focus:border-green-500 transition-colors" />

Clear to auto-calculate from macro goals @@ -242,22 +237,19 @@ >{field.label} ({field.unit}) - {/each} - + diff --git a/src/lib/components/diary/MealCard.svelte b/src/lib/components/diary/MealCard.svelte index 31734ab..da675e8 100644 --- a/src/lib/components/diary/MealCard.svelte +++ b/src/lib/components/diary/MealCard.svelte @@ -7,6 +7,8 @@ import { deleteEntry } from "$lib/api/entries"; import { goto } from "$app/navigation"; import Sheet from "$lib/components/ui/Sheet.svelte"; + import Button from "$lib/components/ui/Button.svelte"; + import Input from "$lib/components/ui/Input.svelte"; interface Props { meal: Meal; @@ -92,6 +94,8 @@ > + @@ -278,20 +278,16 @@ title="Rename meal" >

- - +
diff --git a/src/lib/components/ui/Button.svelte b/src/lib/components/ui/Button.svelte new file mode 100644 index 0000000..3788f16 --- /dev/null +++ b/src/lib/components/ui/Button.svelte @@ -0,0 +1,25 @@ + + + diff --git a/src/lib/components/ui/GramsStepper.svelte b/src/lib/components/ui/GramsStepper.svelte new file mode 100644 index 0000000..d9470f4 --- /dev/null +++ b/src/lib/components/ui/GramsStepper.svelte @@ -0,0 +1,69 @@ + + +
+ + e.currentTarget.select()} + onkeydown={(e) => { + if (e.key === "Enter") onEnter?.(); + }} + class={inputClass} + /> + +
diff --git a/src/lib/components/ui/Input.svelte b/src/lib/components/ui/Input.svelte new file mode 100644 index 0000000..befd882 --- /dev/null +++ b/src/lib/components/ui/Input.svelte @@ -0,0 +1,30 @@ + + + diff --git a/src/lib/components/ui/Sheet.svelte b/src/lib/components/ui/Sheet.svelte index 23297f6..e66a93a 100644 --- a/src/lib/components/ui/Sheet.svelte +++ b/src/lib/components/ui/Sheet.svelte @@ -44,9 +44,9 @@ {#if open} - - + diff --git a/src/lib/offline/db.ts b/src/lib/offline/db.ts index 0f053ea..4cd9675 100644 --- a/src/lib/offline/db.ts +++ b/src/lib/offline/db.ts @@ -16,20 +16,25 @@ let dbInstance: IDBPDatabase | null = null; export async function getDb() { if (dbInstance) return dbInstance; - dbInstance = await openDB(DB_NAME, DB_VERSION, { - upgrade(db, oldVersion) { - if (oldVersion < 1) { - db.createObjectStore('diaries', { keyPath: 'date' }); - db.createObjectStore('mutation_queue', { keyPath: 'id', autoIncrement: true }); - } - if (oldVersion < 2) { - if (!db.objectStoreNames.contains('products')) { - db.createObjectStore('products', { keyPath: 'id' }); + try { + dbInstance = await openDB(DB_NAME, DB_VERSION, { + upgrade(db, oldVersion) { + if (oldVersion < 1) { + db.createObjectStore('diaries', { keyPath: 'date' }); + 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; + } catch (e) { + dbInstance = null; + throw e; + } } // ── Diary cache ────────────────────────────────────────────────────────────── diff --git a/src/lib/offline/sync.ts b/src/lib/offline/sync.ts index 5ddd226..df6985a 100644 --- a/src/lib/offline/sync.ts +++ b/src/lib/offline/sync.ts @@ -28,6 +28,7 @@ export async function syncOfflineQueue(): Promise { synced++; } else { // Non-retryable failure (e.g. 400 validation error) — drop it to unblock the queue + console.warn(`[sync] dropping failed mutation ${mutation.method} ${mutation.url}: ${res.status}`); await dequeueMutation(mutation.id!); } } catch { diff --git a/src/routes/(app)/diary/[date]/add-entry/+page.svelte b/src/routes/(app)/diary/[date]/add-entry/+page.svelte index 876cdfb..d68926c 100644 --- a/src/routes/(app)/diary/[date]/add-entry/+page.svelte +++ b/src/routes/(app)/diary/[date]/add-entry/+page.svelte @@ -5,11 +5,15 @@ import { listProducts, getProductByBarcode } from "$lib/api/products"; import { cacheProducts, searchCachedProducts } from "$lib/offline/db"; import { offlineAddEntry } from "$lib/offline/mutations"; + import { ApiError, extractErrorMessage } from "$lib/api/client"; 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"; import BarcodeScanner from "$lib/components/ui/BarcodeScanner.svelte"; + import Button from "$lib/components/ui/Button.svelte"; + import Input from "$lib/components/ui/Input.svelte"; + import GramsStepper from "$lib/components/ui/GramsStepper.svelte"; import { kcal, g } from "$lib/utils/format"; import { today } from "$lib/utils/date"; @@ -36,11 +40,6 @@ let gramsInput = $state(null); - $effect(() => { - if (!selectedProduct) setTimeout(() => searchInput?.focus(), 50); - else setTimeout(() => gramsInput?.focus(), 50); - }); - function handleSearch(value: string) { q = value; clearTimeout(debounceTimer); @@ -64,7 +63,7 @@ return searchCachedProducts(debouncedQ); } }, - staleTime: 0, + staleTime: 30_000, })); function selectProduct(product: Product) { @@ -80,8 +79,8 @@ try { const product = await getProductByBarcode(barcode); selectProduct(product); - } catch (err: any) { - if (err?.status === 404) { + } catch (err: unknown) { + if (err instanceof ApiError && err.status === 404) { goto(`/products/new?barcode=${encodeURIComponent(barcode)}`); } else { scanError = "Could not look up barcode. Try searching manually."; @@ -98,8 +97,8 @@ try { await offlineAddEntry(queryClient, date, mealId, selectedProduct, grams); goto(`/diary/${date}`); - } catch (e: any) { - error = e.message ?? "Failed to add entry"; + } catch (e: unknown) { + error = extractErrorMessage(e, "Failed to add entry"); submitting = false; } } @@ -115,6 +114,11 @@ } : null, ); + + $effect(() => { + if (!selectedProduct) setTimeout(() => searchInput?.focus(), 50); + else setTimeout(() => gramsInput?.focus(), 50); + }); {#if scannerOpen} @@ -144,20 +148,21 @@ d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /> - handleSearch(e.currentTarget.value)} + oninput={(e) => handleSearch((e.currentTarget as HTMLInputElement).value)} onkeydown={(e) => { if (e.key === "Enter") { const first = productsQuery.data?.[0]; if (first) selectProduct(first); } }} - class="w-full bg-zinc-900 border border-zinc-700 rounded-xl pl-9 pr-4 py-2.5 text-sm text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-green-500 transition-colors" + class="text-sm placeholder-zinc-500 pl-9" /> @@ -295,54 +300,24 @@ {/if} -
- - e.currentTarget.select()} - onkeydown={(e) => { - if (e.key === "Enter") handleAddEntry(); - }} - class="flex-1 bg-zinc-800 border border-zinc-700 rounded-xl px-4 py-2.5 text-center text-xl font-semibold focus:outline-none focus:border-green-500 transition-colors" - /> - +
+
{#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 c3d4c6e..4ebfaa8 100644 --- a/src/routes/(app)/diary/[date]/add-meal/+page.svelte +++ b/src/routes/(app)/diary/[date]/add-meal/+page.svelte @@ -8,6 +8,8 @@ import { listPresets } from "$lib/api/presets"; import type { Preset } from "$lib/types/api"; import TopBar from "$lib/components/ui/TopBar.svelte"; + import Button from "$lib/components/ui/Button.svelte"; + import Input from "$lib/components/ui/Input.svelte"; import { kcal, g } from "$lib/utils/format"; import { today } from "$lib/utils/date"; @@ -45,6 +47,12 @@ queryFn: () => listPresets(30), })); + const filteredPresets = $derived( + (presetsQuery.data ?? []).filter( + (p) => !presetDebounced || p.name.toLowerCase().includes(presetDebounced.toLowerCase()), + ), + ); + async function handleCreateNew(e: SubmitEvent) { e.preventDefault(); submitting = true; @@ -99,39 +107,36 @@ - - + {:else}
- handlePresetSearch(e.currentTarget.value)} + oninput={(e) => handlePresetSearch((e.currentTarget as HTMLInputElement).value)} autofocus - class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-4 py-2.5 text-sm text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-green-500 transition-colors" + class="text-sm placeholder-zinc-500" />
@@ -145,18 +150,14 @@
{/each} - {:else if (presetsQuery.data ?? []).filter((p) => !presetDebounced || p.name - .toLowerCase() - .includes(presetDebounced.toLowerCase())).length === 0} + {:else if filteredPresets.length === 0}

No presets yet

Save a meal as preset from the diary view

{:else}
    - {#each (presetsQuery.data ?? []).filter((p) => !presetDebounced || p.name - .toLowerCase() - .includes(presetDebounced.toLowerCase())) as preset (preset.id)} + {#each filteredPresets as preset (preset.id)}
  • - e.currentTarget.select()} - onkeydown={(e) => { - if (e.key === "Enter") handleSave(); - }} - class="flex-1 bg-zinc-900 border border-zinc-700 rounded-xl px-4 py-3 text-center text-2xl font-semibold focus:outline-none focus:border-green-500 transition-colors" - /> - - +
    - +
    {/if} diff --git a/src/routes/(app)/presets/+page.svelte b/src/routes/(app)/presets/+page.svelte index 3b1faad..4de8b94 100644 --- a/src/routes/(app)/presets/+page.svelte +++ b/src/routes/(app)/presets/+page.svelte @@ -15,6 +15,9 @@ import { kcal, g } from "$lib/utils/format"; import TopBar from "$lib/components/ui/TopBar.svelte"; import Sheet from "$lib/components/ui/Sheet.svelte"; + import Button from "$lib/components/ui/Button.svelte"; + import Input from "$lib/components/ui/Input.svelte"; + import GramsStepper from "$lib/components/ui/GramsStepper.svelte"; import type { Preset, PresetEntry, Product } from "$lib/types/api"; const queryClient = useQueryClient(); @@ -248,12 +251,13 @@ d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /> - handleSearch(e.currentTarget.value)} - class="w-full bg-zinc-900 border border-zinc-700 rounded-xl pl-9 pr-4 py-2.5 text-sm text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-green-500 transition-colors" + oninput={(e) => handleSearch((e.currentTarget as HTMLInputElement).value)} + class="text-sm placeholder-zinc-500 pl-9" /> @@ -511,21 +515,17 @@ title="Create new preset" >
    - - +
    @@ -536,21 +536,17 @@ title="Rename preset" >
    - - +
    @@ -565,44 +561,11 @@

    {editingEntry.entry.product.name}

    -
    - - e.currentTarget.select()} - class="flex-1 bg-zinc-800 border border-zinc-700 rounded-xl px-4 py-2.5 text-center text-xl font-semibold focus:outline-none focus:border-green-500 transition-colors" - /> - -
    +
    - + {/if} @@ -620,14 +583,16 @@ {#if selectedProduct === null}
    - handleProductSearch(e.currentTarget.value)} + oninput={(e) => handleProductSearch((e.currentTarget as HTMLInputElement).value)} autofocus - class="w-full bg-zinc-800 border border-zinc-700 rounded-xl px-4 py-2.5 text-sm text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-green-500 transition-colors" + class="text-sm placeholder-zinc-500" /> {#if productsQuery.isPending && productDebouncedQ}

    Searching…

    @@ -673,52 +638,15 @@
    -
    - - e.currentTarget.select()} - class="flex-1 bg-zinc-800 border border-zinc-700 rounded-xl px-4 py-2.5 text-center text-xl font-semibold focus:outline-none focus:border-green-500 transition-colors" - /> - -
    +
    - - + +
    {/if} diff --git a/src/routes/(app)/products/new/+page.svelte b/src/routes/(app)/products/new/+page.svelte index 5fb3f36..15ba991 100644 --- a/src/routes/(app)/products/new/+page.svelte +++ b/src/routes/(app)/products/new/+page.svelte @@ -2,6 +2,8 @@ import { page } from "$app/state"; import { createProduct } from "$lib/api/products"; import TopBar from "$lib/components/ui/TopBar.svelte"; + import Button from "$lib/components/ui/Button.svelte"; + import Input from "$lib/components/ui/Input.svelte"; let name = $state(page.url.searchParams.get("name") ?? ""); let barcode = $state(page.url.searchParams.get("barcode") ?? ""); @@ -52,15 +54,15 @@
    -
    @@ -69,7 +71,7 @@ -
    -
    -
    -
    @@ -138,13 +136,9 @@

    {error}

    {/if} - + diff --git a/src/routes/(app)/settings/+page.svelte b/src/routes/(app)/settings/+page.svelte index 97236b4..14b97bb 100644 --- a/src/routes/(app)/settings/+page.svelte +++ b/src/routes/(app)/settings/+page.svelte @@ -4,6 +4,7 @@ import { updateDiary } from "$lib/api/diary"; import TopBar from "$lib/components/ui/TopBar.svelte"; import Sheet from "$lib/components/ui/Sheet.svelte"; + import Button from "$lib/components/ui/Button.svelte"; import { today } from "$lib/utils/date"; const queryClient = useQueryClient(); @@ -101,7 +102,7 @@ >
    -

    Calories

    +

    Calories

    {#if form.calories_goal === null}
    -

    {field.label}

    +

    {field.label}

    {field.unit} per day

    {error}

    {/if} - + {/if} @@ -174,15 +178,7 @@ Override today's diary macro goals with these values as well?

    - - + +
    diff --git a/src/routes/(auth)/login/+page.svelte b/src/routes/(auth)/login/+page.svelte index 5a5abbf..8f31523 100644 --- a/src/routes/(auth)/login/+page.svelte +++ b/src/routes/(auth)/login/+page.svelte @@ -4,6 +4,8 @@ import { auth } from "$lib/auth/store.svelte"; import { onMount } from "svelte"; import { today } from "$lib/utils/date"; + import Button from "$lib/components/ui/Button.svelte"; + import Input from "$lib/components/ui/Input.svelte"; let username = $state(""); let password = $state(""); @@ -39,13 +41,12 @@ for="username" class="block text-sm font-medium text-zinc-400 mb-1">Username -
    @@ -54,13 +55,12 @@ for="password" class="block text-sm font-medium text-zinc-400 mb-1">Password - @@ -68,13 +68,9 @@

    {error}

    {/if} - +

    diff --git a/src/routes/(auth)/register/+page.svelte b/src/routes/(auth)/register/+page.svelte index a10ea8a..d847d68 100644 --- a/src/routes/(auth)/register/+page.svelte +++ b/src/routes/(auth)/register/+page.svelte @@ -3,6 +3,9 @@ import { goto } from "$app/navigation"; import { onMount } from "svelte"; import { PUBLIC_TURNSTILE_SITE_KEY } from "$env/static/public"; + import { extractErrorMessage } from "$lib/api/client"; + import Button from "$lib/components/ui/Button.svelte"; + import Input from "$lib/components/ui/Input.svelte"; let username = $state(""); let password = $state(""); @@ -52,8 +55,7 @@ await register(username, password, captchaToken); goto(`/settings`); } catch (err: unknown) { - const e2 = err as { detail?: { detail?: string } }; - error = e2.detail?.detail ?? "Registration failed"; + error = extractErrorMessage(err, "Registration failed"); } finally { loading = false; } @@ -70,13 +72,12 @@ for="username" class="block text-sm font-medium text-zinc-400 mb-1">Username - @@ -85,13 +86,12 @@ for="password" class="block text-sm font-medium text-zinc-400 mb-1">Password - @@ -101,13 +101,12 @@ class="block text-sm font-medium text-zinc-400 mb-1" >Confirm password - @@ -117,13 +116,9 @@

    {error}

    {/if} - +