From 4eef8a0f052cb3998b378e0fde7182b81d2c4f5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Doma=C5=84ski?= Date: Thu, 16 Apr 2026 11:37:28 +0200 Subject: [PATCH] [ts] lint --- src/app.d.ts | 16 +- src/hooks.client.ts | 28 +- src/lib/api/auth.ts | 84 ++-- src/lib/api/client.ts | 256 +++++----- src/lib/api/diary.ts | 22 +- src/lib/api/entries.ts | 6 +- src/lib/api/meals.ts | 16 +- src/lib/api/products.ts | 20 +- src/lib/api/settings.ts | 4 +- src/lib/auth/store.svelte.ts | 60 +-- .../components/diary/CalendarPicker.svelte | 176 ++++--- src/lib/components/diary/DateNav.svelte | 86 ++-- src/lib/components/diary/EntryRow.svelte | 83 ++-- src/lib/components/diary/MacroSummary.svelte | 422 +++++++++------- src/lib/components/diary/MealCard.svelte | 450 ++++++++++-------- src/lib/components/ui/BarcodeScanner.svelte | 323 ++++++++----- src/lib/components/ui/Sheet.svelte | 137 +++--- src/lib/components/ui/TopBar.svelte | 68 +-- src/lib/offline/db.ts | 90 ++-- src/lib/offline/mutations.ts | 242 +++++----- src/lib/offline/network.svelte.ts | 20 +- src/lib/offline/sync.ts | 52 +- src/lib/types/api.ts | 152 +++--- src/lib/utils/date.ts | 28 +- src/lib/utils/format.ts | 4 +- src/routes/(app)/+layout.svelte | 426 +++++++++++------ src/routes/(app)/diary/+page.svelte | 12 +- src/routes/(app)/diary/[date]/+page.svelte | 403 ++++++++-------- .../(app)/diary/[date]/add-meal/+page.svelte | 312 ++++++------ src/routes/(app)/diary/today/+page.ts | 2 +- src/routes/(app)/products/new/+page.svelte | 241 +++++----- src/routes/(app)/settings/+page.svelte | 318 +++++++------ src/routes/+layout.svelte | 42 +- src/routes/+page.svelte | 22 +- 34 files changed, 2561 insertions(+), 2062 deletions(-) diff --git a/src/app.d.ts b/src/app.d.ts index da08e6d..7d0947d 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -1,13 +1,13 @@ // See https://svelte.dev/docs/kit/types#app.d.ts // for information about these interfaces declare global { - namespace App { - // interface Error {} - // interface Locals {} - // interface PageData {} - // interface PageState {} - // interface Platform {} - } + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } } -export {}; +export { }; diff --git a/src/hooks.client.ts b/src/hooks.client.ts index a61d47f..9d18e1c 100644 --- a/src/hooks.client.ts +++ b/src/hooks.client.ts @@ -1,20 +1,20 @@ import type { HandleClientError } from '@sveltejs/kit'; export const handleError: HandleClientError = ({ error }) => { - const msg = error instanceof Error ? error.message : String(error); + const msg = error instanceof Error ? error.message : String(error); - // SvelteKit lazy-loads route chunks via dynamic import(). When offline and the - // chunk isn't cached, the import fails with this error. Show a helpful message - // instead of the generic "Internal Error" page. - if ( - msg.includes('Importing a module script failed') || - msg.includes('Failed to fetch dynamically imported module') || - msg.includes('Unable to preload CSS') - ) { - return { - message: 'App not cached yet. Open the app while online at least once to enable offline use.' - }; - } + // SvelteKit lazy-loads route chunks via dynamic import(). When offline and the + // chunk isn't cached, the import fails with this error. Show a helpful message + // instead of the generic "Internal Error" page. + if ( + msg.includes('Importing a module script failed') || + msg.includes('Failed to fetch dynamically imported module') || + msg.includes('Unable to preload CSS') + ) { + return { + message: 'App not cached yet. Open the app while online at least once to enable offline use.' + }; + } - return { message: msg || 'An unexpected error occurred.' }; + return { message: msg || 'An unexpected error occurred.' }; }; diff --git a/src/lib/api/auth.ts b/src/lib/api/auth.ts index ff22ad8..4b63213 100644 --- a/src/lib/api/auth.ts +++ b/src/lib/api/auth.ts @@ -4,56 +4,56 @@ import type { Token } from '$lib/types/api'; const BASE = '/api'; export async function login(username: string, password: string): Promise { - const body = new URLSearchParams({ username, password }); - const res = await fetch(`${BASE}/token`, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: body.toString() - }); - if (!res.ok) { - const err = await res.json().catch(() => ({})); - throw Object.assign(new Error('Login failed'), { status: res.status, detail: err }); - } - const token: Token = await res.json(); - auth.setTokens(token.access_token, token.refresh_token); + const body = new URLSearchParams({ username, password }); + const res = await fetch(`${BASE}/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString() + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw Object.assign(new Error('Login failed'), { status: res.status, detail: err }); + } + const token: Token = await res.json(); + auth.setTokens(token.access_token, token.refresh_token); } export async function register(username: string, password: string, captchaToken: string): Promise { - const res = await fetch(`${BASE}/user`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ username, password, captcha_token: captchaToken }) - }); - if (!res.ok) { - const err = await res.json().catch(() => ({})); - throw Object.assign(new Error('Registration failed'), { status: res.status, detail: err }); - } - const token: Token = await res.json(); - auth.setTokens(token.access_token, token.refresh_token); + const res = await fetch(`${BASE}/user`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password, captcha_token: captchaToken }) + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw Object.assign(new Error('Registration failed'), { status: res.status, detail: err }); + } + const token: Token = await res.json(); + auth.setTokens(token.access_token, token.refresh_token); } export async function tryRestoreSession(): Promise { - const refreshToken = auth.getRefreshToken(); - if (!refreshToken) return false; + const refreshToken = auth.getRefreshToken(); + if (!refreshToken) return false; - try { - const res = await fetch(`${BASE}/token/refresh`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ refresh_token: refreshToken }) - }); - if (!res.ok) { - auth.clear(); - return false; - } - const token: Token = await res.json(); - auth.setTokens(token.access_token, token.refresh_token); - return true; - } catch { - return false; - } + try { + const res = await fetch(`${BASE}/token/refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh_token: refreshToken }) + }); + if (!res.ok) { + auth.clear(); + return false; + } + const token: Token = await res.json(); + auth.setTokens(token.access_token, token.refresh_token); + return true; + } catch { + return false; + } } export function logout(): void { - auth.clear(); + auth.clear(); } diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index 5adb2fa..4dc8b5f 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -9,172 +9,172 @@ let isRefreshing = false; let refreshPromise: Promise | null = null; async function doRefresh(): Promise { - const refreshToken = auth.getRefreshToken(); - if (!refreshToken) return null; + const refreshToken = auth.getRefreshToken(); + if (!refreshToken) return null; - try { - const res = await fetch(`${BASE}/token/refresh`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ refresh_token: refreshToken }) - }); - if (!res.ok) return null; - const data = await res.json(); - auth.setTokens(data.access_token, data.refresh_token); - return data.access_token; - } catch { - return null; - } + try { + const res = await fetch(`${BASE}/token/refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh_token: refreshToken }) + }); + if (!res.ok) return null; + const data = await res.json(); + auth.setTokens(data.access_token, data.refresh_token); + return data.access_token; + } catch { + return null; + } } async function getValidToken(): Promise { - if (auth.accessToken) return auth.accessToken; + if (auth.accessToken) return auth.accessToken; - // Deduplicate concurrent refresh calls - if (isRefreshing) return refreshPromise; - isRefreshing = true; - refreshPromise = doRefresh().finally(() => { - isRefreshing = false; - refreshPromise = null; - }); - return refreshPromise; + // Deduplicate concurrent refresh calls + if (isRefreshing) return refreshPromise; + isRefreshing = true; + refreshPromise = doRefresh().finally(() => { + isRefreshing = false; + refreshPromise = null; + }); + return refreshPromise; } export async function apiFetch( - path: string, - options: RequestInit = {}, - retry = true + path: string, + options: RequestInit = {}, + retry = true ): Promise { - const token = await getValidToken(); + const token = await getValidToken(); - const headers = new Headers(options.headers); - if (token) headers.set('Authorization', `Bearer ${token}`); + const headers = new Headers(options.headers); + if (token) headers.set('Authorization', `Bearer ${token}`); - const res = await fetch(`${BASE}${path}`, { ...options, headers }); + const res = await fetch(`${BASE}${path}`, { ...options, headers }); - if (res.status === 401 && retry) { - // Force a refresh and retry once - auth.setAccessToken(''); // clear so getValidToken triggers refresh - const newToken = await doRefresh(); - if (!newToken) { - auth.clear(); - goto('/login'); - return res; - } - auth.setAccessToken(newToken); - headers.set('Authorization', `Bearer ${newToken}`); - return fetch(`${BASE}${path}`, { ...options, headers }); - } + if (res.status === 401 && retry) { + // Force a refresh and retry once + auth.setAccessToken(''); // clear so getValidToken triggers refresh + const newToken = await doRefresh(); + if (!newToken) { + auth.clear(); + goto('/login'); + return res; + } + auth.setAccessToken(newToken); + headers.set('Authorization', `Bearer ${newToken}`); + return fetch(`${BASE}${path}`, { ...options, headers }); + } - return res; + return res; } export async function apiGet(path: string): Promise { - const res = await apiFetch(path); - if (!res.ok) throw new Error(`GET ${path} failed: ${res.status}`); - return res.json(); + const res = await apiFetch(path); + if (!res.ok) throw new Error(`GET ${path} failed: ${res.status}`); + return res.json(); } // Auth endpoints must never be queued (token ops need live responses) function isAuthPath(path: string) { - return path.startsWith('/token/'); + return path.startsWith('/token/'); } // A TypeError thrown by fetch() always means a network-level failure function isNetworkFailure(e: unknown): boolean { - return e instanceof TypeError; + return e instanceof TypeError; } // 5xx from the proxy/server when backend is down — treat same as offline function isServerUnavailable(res: Response): boolean { - return res.status >= 500; + return res.status >= 500; } async function queueMutation(method: 'POST' | 'PATCH' | 'DELETE', path: string, body: unknown): Promise { - network.setOffline(); - await enqueueMutation({ method, url: path, body }); - network.incrementPending(); + network.setOffline(); + await enqueueMutation({ method, url: path, body }); + network.incrementPending(); } export async function apiPost(path: string, body: unknown): Promise { - if (!navigator.onLine && !isAuthPath(path)) { - await queueMutation('POST', path, body); - return {} as T; - } - let res: Response; - try { - res = await apiFetch(path, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body) - }); - } catch (e) { - if (!isAuthPath(path) && isNetworkFailure(e)) { - await queueMutation('POST', path, body); - return {} as T; - } - throw e; - } - if (!res.ok) { - if (!isAuthPath(path) && isServerUnavailable(res)) { - await queueMutation('POST', path, body); - return {} as T; - } - const err = await res.json().catch(() => ({})); - throw Object.assign(new Error(`POST ${path} failed: ${res.status}`), { status: res.status, detail: err }); - } - return res.json(); + if (!navigator.onLine && !isAuthPath(path)) { + await queueMutation('POST', path, body); + return {} as T; + } + let res: Response; + try { + res = await apiFetch(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + } catch (e) { + if (!isAuthPath(path) && isNetworkFailure(e)) { + await queueMutation('POST', path, body); + return {} as T; + } + throw e; + } + if (!res.ok) { + if (!isAuthPath(path) && isServerUnavailable(res)) { + await queueMutation('POST', path, body); + return {} as T; + } + const err = await res.json().catch(() => ({})); + throw Object.assign(new Error(`POST ${path} failed: ${res.status}`), { status: res.status, detail: err }); + } + return res.json(); } export async function apiPatch(path: string, body: unknown): Promise { - if (!navigator.onLine && !isAuthPath(path)) { - await queueMutation('PATCH', path, body); - return {} as T; - } - let res: Response; - try { - res = await apiFetch(path, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body) - }); - } catch (e) { - if (!isAuthPath(path) && isNetworkFailure(e)) { - await queueMutation('PATCH', path, body); - return {} as T; - } - throw e; - } - if (!res.ok) { - if (!isAuthPath(path) && isServerUnavailable(res)) { - await queueMutation('PATCH', path, body); - return {} as T; - } - throw new Error(`PATCH ${path} failed: ${res.status}`); - } - return res.json(); + if (!navigator.onLine && !isAuthPath(path)) { + await queueMutation('PATCH', path, body); + return {} as T; + } + let res: Response; + try { + res = await apiFetch(path, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + } catch (e) { + if (!isAuthPath(path) && isNetworkFailure(e)) { + await queueMutation('PATCH', path, body); + return {} as T; + } + throw e; + } + if (!res.ok) { + if (!isAuthPath(path) && isServerUnavailable(res)) { + await queueMutation('PATCH', path, body); + return {} as T; + } + throw new Error(`PATCH ${path} failed: ${res.status}`); + } + return res.json(); } export async function apiDelete(path: string): Promise { - if (!navigator.onLine && !isAuthPath(path)) { - await queueMutation('DELETE', path, undefined); - return; - } - let res: Response; - try { - res = await apiFetch(path, { method: 'DELETE' }); - } catch (e) { - if (!isAuthPath(path) && isNetworkFailure(e)) { - await queueMutation('DELETE', path, undefined); - return; - } - throw e; - } - if (!res.ok) { - if (!isAuthPath(path) && isServerUnavailable(res)) { - await queueMutation('DELETE', path, undefined); - return; - } - throw new Error(`DELETE ${path} failed: ${res.status}`); - } + if (!navigator.onLine && !isAuthPath(path)) { + await queueMutation('DELETE', path, undefined); + return; + } + let res: Response; + try { + res = await apiFetch(path, { method: 'DELETE' }); + } catch (e) { + if (!isAuthPath(path) && isNetworkFailure(e)) { + await queueMutation('DELETE', path, undefined); + return; + } + throw e; + } + if (!res.ok) { + if (!isAuthPath(path) && isServerUnavailable(res)) { + await queueMutation('DELETE', path, undefined); + return; + } + throw new Error(`DELETE ${path} failed: ${res.status}`); + } } diff --git a/src/lib/api/diary.ts b/src/lib/api/diary.ts index 3c1c643..8d9bc58 100644 --- a/src/lib/api/diary.ts +++ b/src/lib/api/diary.ts @@ -2,22 +2,22 @@ import { apiFetch, apiPost, apiPatch } from './client'; import type { Diary } from '$lib/types/api'; export async function getDiary(date: string): Promise { - const res = await apiFetch(`/diary/${date}`); - if (res.status === 404) return null; - if (!res.ok) throw new Error(`GET /diary/${date} failed: ${res.status}`); - return res.json(); + const res = await apiFetch(`/diary/${date}`); + if (res.status === 404) return null; + if (!res.ok) throw new Error(`GET /diary/${date} failed: ${res.status}`); + return res.json(); } export function createDiary(date: string): Promise { - return apiPost('/diary', { date }); + return apiPost('/diary', { date }); } export function updateDiary(date: string, patch: { - protein_goal?: number | null; - carb_goal?: number | null; - fat_goal?: number | null; - fiber_goal?: number | null; - calories_goal?: number | null; + protein_goal?: number | null; + carb_goal?: number | null; + fat_goal?: number | null; + fiber_goal?: number | null; + calories_goal?: number | null; }): Promise { - return apiPatch(`/diary/${date}`, patch); + return apiPatch(`/diary/${date}`, patch); } diff --git a/src/lib/api/entries.ts b/src/lib/api/entries.ts index 55fb9b6..21f92a0 100644 --- a/src/lib/api/entries.ts +++ b/src/lib/api/entries.ts @@ -2,13 +2,13 @@ import { apiPost, apiPatch, apiDelete } from './client'; import type { Entry } from '$lib/types/api'; export function createEntry(date: string, mealId: number, productId: number, grams: number): Promise { - return apiPost(`/diary/${date}/meal/${mealId}/entry`, { product_id: productId, grams }); + return apiPost(`/diary/${date}/meal/${mealId}/entry`, { product_id: productId, grams }); } export function updateEntry(date: string, mealId: number, id: number, patch: { grams?: number }): Promise { - return apiPatch(`/diary/${date}/meal/${mealId}/entry/${id}`, patch); + return apiPatch(`/diary/${date}/meal/${mealId}/entry/${id}`, patch); } export function deleteEntry(date: string, mealId: number, id: number): Promise { - return apiDelete(`/diary/${date}/meal/${mealId}/entry/${id}`); + return apiDelete(`/diary/${date}/meal/${mealId}/entry/${id}`); } diff --git a/src/lib/api/meals.ts b/src/lib/api/meals.ts index d2e3ddc..3951faa 100644 --- a/src/lib/api/meals.ts +++ b/src/lib/api/meals.ts @@ -2,24 +2,24 @@ import { apiPost, apiDelete, apiPatch } from './client'; import type { Meal, Preset } from '$lib/types/api'; export function createMeal(date: string, name: string): Promise { - return apiPost(`/diary/${date}/meal`, { name }); + return apiPost(`/diary/${date}/meal`, { name }); } export function renameMeal(date: string, id: number, name: string): Promise { - return apiPatch(`/diary/${date}/meal/${id}`, { name }); + return apiPatch(`/diary/${date}/meal/${id}`, { name }); } export function deleteMeal(date: string, id: number): Promise { - return apiDelete(`/diary/${date}/meal/${id}`); + return apiDelete(`/diary/${date}/meal/${id}`); } export function saveMealAsPreset(date: string, mealId: number, name?: string): Promise { - return apiPost(`/diary/${date}/meal/${mealId}/preset`, name ? { name } : {}); + return apiPost(`/diary/${date}/meal/${mealId}/preset`, name ? { name } : {}); } export function createMealFromPreset(date: string, presetId: number, name?: string): Promise { - return apiPost(`/diary/${date}/meal/from_preset`, { - preset_id: presetId, - ...(name ? { name } : {}) - }); + return apiPost(`/diary/${date}/meal/from_preset`, { + preset_id: presetId, + ...(name ? { name } : {}) + }); } diff --git a/src/lib/api/products.ts b/src/lib/api/products.ts index 35c0dfe..2f252e9 100644 --- a/src/lib/api/products.ts +++ b/src/lib/api/products.ts @@ -2,21 +2,21 @@ import { apiGet, apiPost } from './client'; import type { Product } from '$lib/types/api'; export function listProducts(q = '', limit = 20, offset = 0): Promise { - const params = new URLSearchParams({ q, limit: String(limit), offset: String(offset) }); - return apiGet(`/product?${params}`); + const params = new URLSearchParams({ q, limit: String(limit), offset: String(offset) }); + return apiGet(`/product?${params}`); } export function createProduct(data: { - name: string; - protein: number; - carb: number; - fat: number; - fiber: number; - barcode?: string; + name: string; + protein: number; + carb: number; + fat: number; + fiber: number; + barcode?: string; }): Promise { - return apiPost('/product', data); + return apiPost('/product', data); } export function getProductByBarcode(barcode: string): Promise { - return apiGet(`/product/barcode/${encodeURIComponent(barcode)}`); + return apiGet(`/product/barcode/${encodeURIComponent(barcode)}`); } diff --git a/src/lib/api/settings.ts b/src/lib/api/settings.ts index 0c91861..ad1e5d0 100644 --- a/src/lib/api/settings.ts +++ b/src/lib/api/settings.ts @@ -2,9 +2,9 @@ import { apiGet, apiPatch } from './client'; import type { UserSettings } from '$lib/types/api'; export function getUserSettings(): Promise { - return apiGet('/user/settings'); + return apiGet('/user/settings'); } export function updateUserSettings(patch: Omit, 'id' | 'calories_goal'> & { calories_goal?: number | null }): Promise { - return apiPatch('/user/settings', patch); + return apiPatch('/user/settings', patch); } diff --git a/src/lib/auth/store.svelte.ts b/src/lib/auth/store.svelte.ts index c672007..340b47e 100644 --- a/src/lib/auth/store.svelte.ts +++ b/src/lib/auth/store.svelte.ts @@ -3,44 +3,44 @@ import type { User } from '$lib/types/api'; const REFRESH_TOKEN_KEY = 'fooder_refresh_token'; interface AuthState { - accessToken: string | null; - user: User | null; + accessToken: string | null; + user: User | null; } let state = $state({ - accessToken: null, - user: null + accessToken: null, + user: null }); export const auth = { - get accessToken() { - return state.accessToken; - }, - get user() { - return state.user; - }, - get isAuthenticated() { - return state.accessToken !== null; - }, + get accessToken() { + return state.accessToken; + }, + get user() { + return state.user; + }, + get isAuthenticated() { + return state.accessToken !== null; + }, - setTokens(accessToken: string, refreshToken: string, user?: User) { - state.accessToken = accessToken; - if (user) state.user = user; - localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken); - }, + setTokens(accessToken: string, refreshToken: string, user?: User) { + state.accessToken = accessToken; + if (user) state.user = user; + localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken); + }, - setAccessToken(accessToken: string) { - state.accessToken = accessToken; - }, + setAccessToken(accessToken: string) { + state.accessToken = accessToken; + }, - getRefreshToken(): string | null { - if (typeof localStorage === 'undefined') return null; - return localStorage.getItem(REFRESH_TOKEN_KEY); - }, + getRefreshToken(): string | null { + if (typeof localStorage === 'undefined') return null; + return localStorage.getItem(REFRESH_TOKEN_KEY); + }, - clear() { - state.accessToken = null; - state.user = null; - localStorage.removeItem(REFRESH_TOKEN_KEY); - } + clear() { + state.accessToken = null; + state.user = null; + localStorage.removeItem(REFRESH_TOKEN_KEY); + } }; diff --git a/src/lib/components/diary/CalendarPicker.svelte b/src/lib/components/diary/CalendarPicker.svelte index 9ed1dc5..93ae1dc 100644 --- a/src/lib/components/diary/CalendarPicker.svelte +++ b/src/lib/components/diary/CalendarPicker.svelte @@ -1,91 +1,119 @@
-
- - {monthLabel} - -
+
+ + {monthLabel} + +
-
- {#each ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'] as h} -
{h}
- {/each} -
+
+ {#each ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"] as h} +
{h}
+ {/each} +
-
- {#each cells as cell} - {#if cell === null} -
- {:else} - - {/if} - {/each} -
+ ? 'bg-green-600 text-white font-semibold' + : cell === todayStr + ? 'ring-1 ring-green-500 text-green-400' + : 'hover:bg-zinc-800 text-zinc-300'}" + > + {parseInt(cell.slice(8))} + + {/if} + {/each} +
diff --git a/src/lib/components/diary/DateNav.svelte b/src/lib/components/diary/DateNav.svelte index 0915117..3f24d74 100644 --- a/src/lib/components/diary/DateNav.svelte +++ b/src/lib/components/diary/DateNav.svelte @@ -1,45 +1,57 @@
- + - + - +
diff --git a/src/lib/components/diary/EntryRow.svelte b/src/lib/components/diary/EntryRow.svelte index 5ca17f5..fa86dc6 100644 --- a/src/lib/components/diary/EntryRow.svelte +++ b/src/lib/components/diary/EntryRow.svelte @@ -1,44 +1,59 @@
onEdit(entry)} - onkeydown={(e) => e.key === 'Enter' && onEdit(entry)} - class="w-full flex items-center justify-between py-2.5 cursor-pointer group" + role="button" + tabindex="0" + onclick={() => onEdit(entry)} + onkeydown={(e) => e.key === "Enter" && onEdit(entry)} + class="w-full flex items-center justify-between py-2.5 cursor-pointer group" > -
-

{entry.product.name}

-

{entry.grams}g · {kcal(entry.calories)} kcal

-
+
+

{entry.product.name}

+

+ {entry.grams}g · {kcal(entry.calories)} kcal +

+
-
- +
+ - -
+ +
diff --git a/src/lib/components/diary/MacroSummary.svelte b/src/lib/components/diary/MacroSummary.svelte index 75853f3..127bf0d 100644 --- a/src/lib/components/diary/MacroSummary.svelte +++ b/src/lib/components/diary/MacroSummary.svelte @@ -1,203 +1,263 @@
- -
+ +
+ +
+ + + + +
+ {kcal(diary.calories)} + + {diary.calories_goal > 0 ? `/ ${kcal(diary.calories_goal)}` : "kcal"} + +
+
- -
- - - - -
- {kcal(diary.calories)} - - {diary.calories_goal > 0 ? `/ ${kcal(diary.calories_goal)}` : 'kcal'} - -
-
+ +
+ {#each macroRows as macro} +
+
+ {macro.label} + + {macro.value}g{macro.goal > 0 ? ` / ${macro.goal}g` : ""} + +
+
+
+
+
+ {/each} +
- -
- {#each macroRows as macro} -
-
- {macro.label} - - {macro.value}g{macro.goal > 0 ? ` / ${macro.goal}g` : ''} - -
-
-
-
-
- {/each} -
- - - -
+ + +
- syncSettingsOpen = false} title="Update global settings?"> -

Override your default macro goals in user settings with these values as well?

-
- - -
+ (syncSettingsOpen = false)} + title="Update global settings?" +> +

+ Override your default macro goals in user settings with these values as + well? +

+
+ + +
- sheetOpen = false} title="Day goals"> -
- -
-
- - {#if form.calories_goal === null} - auto - {/if} -
- { - const v = e.currentTarget.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

-
+ (sheetOpen = false)} title="Day goals"> + + +
+
+ + {#if form.calories_goal === null} + auto + {/if} +
+ { + const v = e.currentTarget.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 +

+
- {#each [ - { label: 'Protein', key: 'protein_goal' as const, unit: 'g' }, - { label: 'Carbs', key: 'carb_goal' as const, unit: 'g' }, - { label: 'Fat', key: 'fat_goal' as const, unit: 'g' }, - { label: 'Fiber', key: 'fiber_goal' as const, unit: 'g' }, - ] as field} -
- - -
- {/each} + {#each [{ label: "Protein", key: "protein_goal" as const, unit: "g" }, { label: "Carbs", key: "carb_goal" as const, unit: "g" }, { label: "Fat", key: "fat_goal" as const, unit: "g" }, { label: "Fiber", key: "fiber_goal" as const, unit: "g" }] as field} +
+ + +
+ {/each} - - + +
diff --git a/src/lib/components/diary/MealCard.svelte b/src/lib/components/diary/MealCard.svelte index 1f34bf2..53634d6 100644 --- a/src/lib/components/diary/MealCard.svelte +++ b/src/lib/components/diary/MealCard.svelte @@ -1,213 +1,285 @@
- -
- + +
+ -
- - +
+ + - - + + - - + + - - -
-
+ + +
+
- - {#if !collapsed} -
- {#if meal.entries.length === 0} - - {/if} - {#each meal.entries as entry (entry.id)} - - {/each} -
- {/if} + + {#if !collapsed} +
+ {#if meal.entries.length === 0} + + {/if} + {#each meal.entries as entry (entry.id)} + + {/each} +
+ {/if}
- presetOpen = false} title="Save as preset"> -
-
- - -
- -
+ (presetOpen = false)} + title="Save as preset" +> +
+
+ + +
+ +
- renameOpen = false} title="Rename meal"> -
- - -
+ (renameOpen = false)} + title="Rename meal" +> +
+ + +
diff --git a/src/lib/components/ui/BarcodeScanner.svelte b/src/lib/components/ui/BarcodeScanner.svelte index 4ce0762..667801f 100644 --- a/src/lib/components/ui/BarcodeScanner.svelte +++ b/src/lib/components/ui/BarcodeScanner.svelte @@ -1,154 +1,223 @@
- -
- Scan barcode - -
+ +
+ Scan barcode + +
- {#if error} -
-
- - - -

{error}

-
-
- {:else} - -
- - + {#if error} +
+
+ + + +

{error}

+
+
+ {:else} + +
+ + - -
-
+ +
+
- -
- -
+ +
+ +
- -
-
-
-
+ +
+
+
+
- - {#if !detected} -
- {:else} -
- - - -
- {/if} -
-
-
+ + {#if !detected} +
+ {:else} +
+ + + +
+ {/if} +
+
+
- -
-

Point the camera at a barcode

-
- {/if} + +
+

Point the camera at a barcode

+
+ {/if}
diff --git a/src/lib/components/ui/Sheet.svelte b/src/lib/components/ui/Sheet.svelte index 5053ad8..23297f6 100644 --- a/src/lib/components/ui/Sheet.svelte +++ b/src/lib/components/ui/Sheet.svelte @@ -1,86 +1,99 @@ {#if open} - - -
+ + +
- -
- -
+ style="bottom: {bottomOffset}px" + > + +
- {#if title} -

{title}

- {/if} + {#if title} +

{title}

+ {/if} - {@render children()} -
+ {@render children()} + {/if} diff --git a/src/lib/components/ui/TopBar.svelte b/src/lib/components/ui/TopBar.svelte index 5c1505b..c7e385a 100644 --- a/src/lib/components/ui/TopBar.svelte +++ b/src/lib/components/ui/TopBar.svelte @@ -1,37 +1,49 @@ -
- {#if back} - - {/if} +
+ {#if back} + + {/if} -

{title}

+

{title}

- {#if action} - {@render action()} - {/if} + {#if action} + {@render action()} + {/if}
diff --git a/src/lib/offline/db.ts b/src/lib/offline/db.ts index 138d11a..0f053ea 100644 --- a/src/lib/offline/db.ts +++ b/src/lib/offline/db.ts @@ -5,84 +5,84 @@ const DB_NAME = 'fooder'; const DB_VERSION = 2; export interface QueuedMutation { - id?: number; - method: 'POST' | 'PATCH' | 'DELETE'; - url: string; - body: unknown; - createdAt: number; + id?: number; + method: 'POST' | 'PATCH' | 'DELETE'; + url: string; + body: unknown; + createdAt: number; } 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' }); - } - } - } - }); - return dbInstance; + 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' }); + } + } + } + }); + return dbInstance; } // ── Diary cache ────────────────────────────────────────────────────────────── export async function cacheDiary(diary: Diary): Promise { - const db = await getDb(); - await db.put('diaries', diary); + const db = await getDb(); + await db.put('diaries', diary); } export async function getCachedDiary(date: string): Promise { - const db = await getDb(); - return db.get('diaries', date); + 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; + 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); + 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() }); + 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'); + 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); + const db = await getDb(); + await db.delete('mutation_queue', id); } export async function getMutationQueueLength(): Promise { - const db = await getDb(); - return db.count('mutation_queue'); + const db = await getDb(); + return db.count('mutation_queue'); } diff --git a/src/lib/offline/mutations.ts b/src/lib/offline/mutations.ts index ffb5ebe..3bfdd4e 100644 --- a/src/lib/offline/mutations.ts +++ b/src/lib/offline/mutations.ts @@ -9,171 +9,171 @@ 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, - }; + 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 } + 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, - }; + 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 - } + 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 + queryClient: QueryClient, + date: string, + mealId: number, + product: Product, + grams: number ): Promise { - if (network.online) { - await createEntry(date, mealId, product.id, grams); - await queryClient.invalidateQueries({ queryKey: ['diary', date] }); - return; - } + if (network.online) { + await createEntry(date, 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.'); - } + if (mealId < 0) { + throw new Error('Cannot add entry to an unsaved meal — please sync first.'); + } - await enqueueMutation({ method: 'POST', url: `/diary/${date}/meal/${mealId}/entry`, body: { product_id: product.id, grams } }); - network.incrementPending(); + await enqueueMutation({ method: 'POST', url: `/diary/${date}/meal/${mealId}/entry`, body: { 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_id: product.id, product: plainProduct, meal_id: mealId, ...macros }; + 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_id: product.id, 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); - }); + 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); + await persistOptimistic(queryClient, date); } // ── Edit entry ──────────────────────────────────────────────────────────────── export async function offlineEditEntry( - queryClient: QueryClient, - date: string, - entryId: number, - newGrams: number + queryClient: QueryClient, + date: string, + entryId: number, + newGrams: number ): Promise { - const diary = queryClient.getQueryData(['diary', date]); - const mealId = diary?.meals.find(m => m.entries.some(e => e.id === entryId))?.id; + const diary = queryClient.getQueryData(['diary', date]); + const mealId = diary?.meals.find(m => m.entries.some(e => e.id === entryId))?.id; - if (network.online) { - await updateEntry(date, mealId!, entryId, { grams: newGrams }); - await queryClient.invalidateQueries({ queryKey: ['diary', date] }); - return; - } + if (network.online) { + await updateEntry(date, mealId!, entryId, { grams: newGrams }); + await queryClient.invalidateQueries({ queryKey: ['diary', date] }); + return; + } - if (entryId < 0) { - throw new Error('Cannot edit an unsaved entry — please sync first.'); - } + if (entryId < 0) { + throw new Error('Cannot edit an unsaved entry — please sync first.'); + } - await enqueueMutation({ method: 'PATCH', url: `/diary/${date}/meal/${mealId}/entry/${entryId}`, body: { grams: newGrams } }); - network.incrementPending(); + await enqueueMutation({ method: 'PATCH', url: `/diary/${date}/meal/${mealId}/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 }; + 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 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 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); - }); + const updatedEntry: Entry = { ...old, grams: newGrams, ...newMacros }; + const entries = [...meal.entries]; + entries[idx] = updatedEntry; + return addMacros({ ...meal, entries }, delta); + }); - return addMacros({ ...diary, meals }, delta); - }); + return addMacros({ ...diary, meals }, delta); + }); - await persistOptimistic(queryClient, date); + await persistOptimistic(queryClient, date); } // ── Add meal ────────────────────────────────────────────────────────────────── export async function offlineAddMeal( - queryClient: QueryClient, - date: string, - name: string + queryClient: QueryClient, + date: string, + name: string ): Promise { - if (network.online) { - await createMeal(date, name || 'Meal'); - await queryClient.invalidateQueries({ queryKey: ['diary', date] }); - return; - } + if (network.online) { + await createMeal(date, name || 'Meal'); + await queryClient.invalidateQueries({ queryKey: ['diary', date] }); + return; + } - await enqueueMutation({ method: 'POST', url: `/diary/${date}/meal`, body: { name: name || 'Meal' } }); - network.incrementPending(); + await enqueueMutation({ method: 'POST', url: `/diary/${date}/meal`, body: { name: name || 'Meal' } }); + 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: diary?.id ?? 0, - entries: [], - calories: 0, protein: 0, carb: 0, fat: 0, fiber: 0, - }; + 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: diary?.id ?? 0, + 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] }; - }); + queryClient.setQueryData(['diary', date], d => { + if (!d) return d; + return { ...d, meals: [...d.meals, fakeMeal] }; + }); - await persistOptimistic(queryClient, date); + await persistOptimistic(queryClient, date); } diff --git a/src/lib/offline/network.svelte.ts b/src/lib/offline/network.svelte.ts index 68bfc73..0f62022 100644 --- a/src/lib/offline/network.svelte.ts +++ b/src/lib/offline/network.svelte.ts @@ -3,18 +3,18 @@ let _pendingCount = $state(0); let _syncing = $state(false); if (typeof window !== 'undefined') { - window.addEventListener('online', () => { _online = true; }); - window.addEventListener('offline', () => { _online = false; }); + 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; }, + 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; }, - setOffline() { _online = false; } + incrementPending() { _pendingCount++; }, + decrementPending() { _pendingCount = Math.max(0, _pendingCount - 1); }, + setPendingCount(n: number) { _pendingCount = n; }, + setSyncing(v: boolean) { _syncing = v; }, + setOffline() { _online = false; } }; diff --git a/src/lib/offline/sync.ts b/src/lib/offline/sync.ts index 6dbfcee..5ddd226 100644 --- a/src/lib/offline/sync.ts +++ b/src/lib/offline/sync.ts @@ -7,34 +7,34 @@ import { apiFetch } from '$lib/api/client'; * Stops at the first failure to preserve ordering. */ export async function syncOfflineQueue(): Promise { - const queue = await getMutationQueue(); - let synced = 0; + 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'; - } + 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 - }); + 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; - } - } + 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; + return synced; } diff --git a/src/lib/types/api.ts b/src/lib/types/api.ts index 6060dea..96a7004 100644 --- a/src/lib/types/api.ts +++ b/src/lib/types/api.ts @@ -1,105 +1,105 @@ export interface Token { - access_token: string; - refresh_token: string; - token_type: string; + access_token: string; + refresh_token: string; + token_type: string; } export interface User { - username: string; + username: string; } export interface Macros { - calories: number; - protein: number; - carb: number; - fat: number; - fiber: number; + calories: number; + protein: number; + carb: number; + fat: number; + fiber: number; } export interface Product { - id: number; - name: string; - calories: number; - protein: number; - carb: number; - fat: number; - fiber: number; - barcode: string | null; - usage_count_cached?: number | null; + id: number; + name: string; + calories: number; + protein: number; + carb: number; + fat: number; + fiber: number; + barcode: string | null; + usage_count_cached?: number | null; } export interface Entry { - id: number; - grams: number; - product_id: number; - product: Product; - meal_id: number; - calories: number; - protein: number; - carb: number; - fat: number; - fiber: number; + id: number; + grams: number; + product_id: number; + product: Product; + meal_id: number; + calories: number; + protein: number; + carb: number; + fat: number; + fiber: number; } export interface Meal { - id: number; - name: string; - order: number; - diary_id: number; - entries: Entry[]; - calories: number; - protein: number; - carb: number; - fat: number; - fiber: number; + id: number; + name: string; + order: number; + diary_id: number; + entries: Entry[]; + calories: number; + protein: number; + carb: number; + fat: number; + fiber: number; } export interface Diary { - id: number; - date: string; - protein_goal: number; - carb_goal: number; - fat_goal: number; - fiber_goal: number; - calories_goal: number; - meals: Meal[]; - calories: number; - protein: number; - carb: number; - fat: number; - fiber: number; + id: number; + date: string; + protein_goal: number; + carb_goal: number; + fat_goal: number; + fiber_goal: number; + calories_goal: number; + meals: Meal[]; + calories: number; + protein: number; + carb: number; + fat: number; + fiber: number; } export interface Preset { - id: number; - name: string; - user_id: number; - calories: number; - protein: number; - carb: number; - fat: number; - fiber: number; - entries: PresetEntry[]; + id: number; + name: string; + user_id: number; + calories: number; + protein: number; + carb: number; + fat: number; + fiber: number; + entries: PresetEntry[]; } export interface UserSettings { - id: number; - protein_goal: number; - carb_goal: number; - fat_goal: number; - fiber_goal: number; - calories_goal: number; + id: number; + protein_goal: number; + carb_goal: number; + fat_goal: number; + fiber_goal: number; + calories_goal: number; } export interface PresetEntry { - id: number; - grams: number; - product_id: number; - preset_id: number; - product: Product; - calories: number; - protein: number; - carb: number; - fat: number; - fiber: number; + id: number; + grams: number; + product_id: number; + preset_id: number; + product: Product; + calories: number; + protein: number; + carb: number; + fat: number; + fiber: number; } diff --git a/src/lib/utils/date.ts b/src/lib/utils/date.ts index 8c872f2..a038492 100644 --- a/src/lib/utils/date.ts +++ b/src/lib/utils/date.ts @@ -1,25 +1,25 @@ export function toISODate(date: Date): string { - const y = date.getFullYear(); - const m = String(date.getMonth() + 1).padStart(2, '0'); - const d = String(date.getDate()).padStart(2, '0'); - return `${y}-${m}-${d}`; + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; } export function today(): string { - return toISODate(new Date()); + return toISODate(new Date()); } export function addDays(dateStr: string, days: number): string { - const d = new Date(dateStr + 'T00:00:00'); - d.setDate(d.getDate() + days); - return toISODate(d); + const d = new Date(dateStr + 'T00:00:00'); + d.setDate(d.getDate() + days); + return toISODate(d); } export function formatDisplay(dateStr: string): string { - const d = new Date(dateStr + 'T00:00:00'); - const t = today(); - if (dateStr === t) return 'Today'; - if (dateStr === addDays(t, -1)) return 'Yesterday'; - if (dateStr === addDays(t, 1)) return 'Tomorrow'; - return d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' }); + const d = new Date(dateStr + 'T00:00:00'); + const t = today(); + if (dateStr === t) return 'Today'; + if (dateStr === addDays(t, -1)) return 'Yesterday'; + if (dateStr === addDays(t, 1)) return 'Tomorrow'; + return d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' }); } diff --git a/src/lib/utils/format.ts b/src/lib/utils/format.ts index e457a1c..4870187 100644 --- a/src/lib/utils/format.ts +++ b/src/lib/utils/format.ts @@ -1,9 +1,9 @@ /** Round to nearest integer (for kcal) */ export function kcal(x: number): number { - return Math.round(x); + return Math.round(x); } /** Round to 1 decimal (for macros in grams) */ export function g(x: number): number { - return Math.round(x * 10) / 10; + return Math.round(x * 10) / 10; } diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte index 4fdb76a..ba2ab0f 100644 --- a/src/routes/(app)/+layout.svelte +++ b/src/routes/(app)/+layout.svelte @@ -1,173 +1,295 @@ {#if auth.isAuthenticated} -
- - {#if !network.online || network.syncing} -
+ + {#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} + style="padding-top: calc(0.5rem + var(--safe-top))" + > + {#if network.syncing} + + + + Syncing changes… + {:else} + + + + Offline{network.pendingCount > 0 + ? ` · ${network.pendingCount} change${network.pendingCount === 1 ? "" : "s"} pending sync` + : ""} + {/if} +
+ {/if} - - - -
- {@render children()} -
+ +
+ {@render children()} +
- - -
+ > + + + + Settings + + + + {/if} diff --git a/src/routes/(app)/diary/+page.svelte b/src/routes/(app)/diary/+page.svelte index f79938d..d675115 100644 --- a/src/routes/(app)/diary/+page.svelte +++ b/src/routes/(app)/diary/+page.svelte @@ -1,9 +1,9 @@ diff --git a/src/routes/(app)/diary/[date]/+page.svelte b/src/routes/(app)/diary/[date]/+page.svelte index ad64039..75f17f0 100644 --- a/src/routes/(app)/diary/[date]/+page.svelte +++ b/src/routes/(app)/diary/[date]/+page.svelte @@ -1,207 +1,236 @@
- -
- goDate(-1)} - onNext={() => goDate(1)} - isToday={date === today()} - onDateClick={() => calendarOpen = true} - /> -
+ +
+ goDate(-1)} + onNext={() => goDate(1)} + isToday={date === today()} + onDateClick={() => (calendarOpen = true)} + /> +
- -
- {#if diaryQuery.isPending} - -
-
-
-
-
-
-
- {:else if diaryQuery.isError} -
-

Could not load diary

-

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

- -
- {:else if diaryQuery.data === null} -
-

No diary for {formatDisplay(date)}

-

No entries have been tracked for this date.

- -
- {:else if diaryQuery.data} - {@const diary = diaryQuery.data} + +
+ {#if diaryQuery.isPending} + +
+
+
+
+
+
+
+ {:else if diaryQuery.isError} +
+

Could not load diary

+

+ {diaryQuery.error?.message ?? "Unknown error"} +

+ +
+ {:else if diaryQuery.data === null} +
+

No diary for {formatDisplay(date)}

+

No entries have been tracked for this date.

+ +
+ {:else if diaryQuery.data} + {@const diary = diaryQuery.data} -
- -
- -
+
+ +
+ +
- -
- {#each diary.meals as meal (meal.id)} - - {/each} + +
+ {#each diary.meals as meal (meal.id)} + + {/each} - - -
-
- {/if} -
+ + +
+ + {/if} + - - {#if diaryQuery.data != null} - - {/if} + + {#if diaryQuery.data != null} + + {/if} - calendarOpen = false}> - - + (calendarOpen = false)}> + + - commandOpen = false} /> + (commandOpen = false)} + /> diff --git a/src/routes/(app)/diary/[date]/add-meal/+page.svelte b/src/routes/(app)/diary/[date]/add-meal/+page.svelte index 904c355..1180a75 100644 --- a/src/routes/(app)/diary/[date]/add-meal/+page.svelte +++ b/src/routes/(app)/diary/[date]/add-meal/+page.svelte @@ -1,157 +1,185 @@
- + - -
- {#each [{ id: 'new', label: 'New meal' }, { id: 'preset', label: 'From preset', offlineDisabled: true }] as t} - - {/each} -
+ +
+ {#each [{ id: "new", label: "New meal" }, { id: "preset", label: "From preset", offlineDisabled: true }] as t} + + {/each} +
-
- {#if tab === 'new'} -
-
- - -
- -
+
+ {#if tab === "new"} +
+
+ + +
+ +
+ {:else} + +
+ handlePresetSearch(e.currentTarget.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" + /> +
- {:else} - -
- handlePresetSearch(e.currentTarget.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" - /> -
+ {#if error} +

{error}

+ {/if} - {#if error} -

{error}

- {/if} - - {#if presetsQuery.isPending} -
- {#each Array(4) as _} -
- {/each} -
- {:else if (presetsQuery.data ?? []).filter(p => !presetDebounced || p.name.toLowerCase().includes(presetDebounced.toLowerCase())).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} -
- {/if} - {/if} -
+ {#if presetsQuery.isPending} +
+ {#each Array(4) as _} +
+ {/each} +
+ {:else if (presetsQuery.data ?? []).filter((p) => !presetDebounced || p.name + .toLowerCase() + .includes(presetDebounced.toLowerCase())).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} +
+ {/if} + {/if} +
diff --git a/src/routes/(app)/diary/today/+page.ts b/src/routes/(app)/diary/today/+page.ts index c7e1970..1b61b5e 100644 --- a/src/routes/(app)/diary/today/+page.ts +++ b/src/routes/(app)/diary/today/+page.ts @@ -2,5 +2,5 @@ import { redirect } from '@sveltejs/kit'; import { today } from '$lib/utils/date'; export function load() { - throw redirect(307, `/diary/${today()}`); + throw redirect(307, `/diary/${today()}`); } diff --git a/src/routes/(app)/products/new/+page.svelte b/src/routes/(app)/products/new/+page.svelte index 96ff2f6..ee2d267 100644 --- a/src/routes/(app)/products/new/+page.svelte +++ b/src/routes/(app)/products/new/+page.svelte @@ -1,125 +1,144 @@
- history.back()} /> + history.back()} /> -
-
-

All values per 100g

+
+ +

All values per 100g

-
- - -
+
+ + +
-
-
- - -
-
- - -
-
- - -
-
- - -
-
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
-
- Calories - {calories} kcal -
+
+ Calories + {calories} kcal +
- {#if error} -

{error}

- {/if} + {#if error} +

{error}

+ {/if} - - -
+ + +
diff --git a/src/routes/(app)/settings/+page.svelte b/src/routes/(app)/settings/+page.svelte index fdaab41..97236b4 100644 --- a/src/routes/(app)/settings/+page.svelte +++ b/src/routes/(app)/settings/+page.svelte @@ -1,168 +1,188 @@
- + -
- {#if settingsQuery.isPending} -
- {#each Array(5) as _} -
- {/each} -
- {:else if settingsQuery.isError} -

Could not load settings

- {:else} -
-
-

Daily goals

-
- -
-
-
-

Calories

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

kcal · clear to auto-calculate

-
- { - const v = e.currentTarget.value; - form.calories_goal = v === '' ? null : Number(v); - }} - class="w-24 bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-right text-sm text-zinc-100 focus:outline-none focus:border-green-500 transition-colors" - /> -
+
+ {#if settingsQuery.isPending} +
+ {#each Array(5) as _} +
+ {/each} +
+ {:else if settingsQuery.isError} +

Could not load settings

+ {:else} + +
+

+ Daily goals +

+
+ +
+
+
+

Calories

+ {#if form.calories_goal === null} + auto + {/if} +
+

+ kcal · clear to auto-calculate +

+
+ { + const v = e.currentTarget.value; + form.calories_goal = v === "" ? null : Number(v); + }} + class="w-24 bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-right text-sm text-zinc-100 focus:outline-none focus:border-green-500 transition-colors" + /> +
- {#each [ - { label: 'Protein', key: 'protein_goal' as const, unit: 'g' }, - { label: 'Carbs', key: 'carb_goal' as const, unit: 'g' }, - { label: 'Fat', key: 'fat_goal' as const, unit: 'g' }, - { label: 'Fiber', key: 'fiber_goal' as const, unit: 'g' }, - ] as field} -
-
-

{field.label}

-

{field.unit} per day

-
- -
- {/each} -
-
+ {#each [{ label: "Protein", key: "protein_goal" as const, unit: "g" }, { label: "Carbs", key: "carb_goal" as const, unit: "g" }, { label: "Fat", key: "fat_goal" as const, unit: "g" }, { label: "Fiber", key: "fiber_goal" as const, unit: "g" }] as field} +
+
+

{field.label}

+

{field.unit} per day

+
+ +
+ {/each} +
+
- {#if error} -

{error}

- {/if} + {#if error} +

{error}

+ {/if} - -
- {/if} -
+ > + {saving ? "Saving…" : saved ? "Saved!" : "Save"} + + + {/if} +
- syncDiaryOpen = false} title="Update today's diary?"> -

Override today's diary macro goals with these values as well?

-
- - -
+ (syncDiaryOpen = false)} + title="Update today's diary?" +> +

+ Override today's diary macro goals with these values as well? +

+
+ + +
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index b63a810..dbc453e 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,30 +1,30 @@ - {#if ready} - {@render children()} - {/if} + {#if ready} + {@render children()} + {/if} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 680dfb0..ef7c0f2 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,14 +1,14 @@