diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..00197fd --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +PUBLIC_TURNSTILE_SITE_KEY=your_site_key_here diff --git a/docker-compose.yml b/docker-compose.yml index c7e6191..cc6f10d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,7 @@ services: - ./vite.config.ts:/app/vite.config.ts environment: - VITE_API_URL=http://host.docker.internal:8000 + - PUBLIC_TURNSTILE_SITE_KEY=key #- VITE_API_URL=https://fooderapi.domandoman.xyz extra_hosts: - "host.docker.internal:host-gateway" diff --git a/src/lib/api/auth.ts b/src/lib/api/auth.ts index 28c2c20..ff22ad8 100644 --- a/src/lib/api/auth.ts +++ b/src/lib/api/auth.ts @@ -1,5 +1,5 @@ import { auth } from '$lib/auth/store.svelte'; -import type { Token, User } from '$lib/types/api'; +import type { Token } from '$lib/types/api'; const BASE = '/api'; @@ -18,17 +18,18 @@ export async function login(username: string, password: string): Promise { auth.setTokens(token.access_token, token.refresh_token); } -export async function register(username: string, password: string): Promise { +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 }) + 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 }); } - return res.json(); + const token: Token = await res.json(); + auth.setTokens(token.access_token, token.refresh_token); } export async function tryRestoreSession(): Promise { diff --git a/src/lib/api/diary.ts b/src/lib/api/diary.ts index 587809c..3c1c643 100644 --- a/src/lib/api/diary.ts +++ b/src/lib/api/diary.ts @@ -1,6 +1,23 @@ -import { apiGet } from './client'; +import { apiFetch, apiPost, apiPatch } from './client'; import type { Diary } from '$lib/types/api'; -export function getDiary(date: string): Promise { - return apiGet(`/diary?date=${date}`); +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(); +} + +export function createDiary(date: string): Promise { + 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; +}): Promise { + return apiPatch(`/diary/${date}`, patch); } diff --git a/src/lib/api/entries.ts b/src/lib/api/entries.ts index 6bf68c7..55fb9b6 100644 --- a/src/lib/api/entries.ts +++ b/src/lib/api/entries.ts @@ -1,14 +1,14 @@ import { apiPost, apiPatch, apiDelete } from './client'; import type { Entry } from '$lib/types/api'; -export function createEntry(mealId: number, productId: number, grams: number): Promise { - return apiPost('/entry', { meal_id: mealId, product_id: productId, grams }); +export function createEntry(date: string, mealId: number, productId: number, grams: number): Promise { + return apiPost(`/diary/${date}/meal/${mealId}/entry`, { product_id: productId, grams }); } -export function updateEntry(id: number, patch: { grams?: number; product_id?: number; meal_id?: number }): Promise { - return apiPatch(`/entry/${id}`, patch); +export function updateEntry(date: string, mealId: number, id: number, patch: { grams?: number }): Promise { + return apiPatch(`/diary/${date}/meal/${mealId}/entry/${id}`, patch); } -export function deleteEntry(id: number): Promise { - return apiDelete(`/entry/${id}`); +export function deleteEntry(date: string, mealId: number, id: number): Promise { + return apiDelete(`/diary/${date}/meal/${mealId}/entry/${id}`); } diff --git a/src/lib/api/meals.ts b/src/lib/api/meals.ts index ecbf42c..d2e3ddc 100644 --- a/src/lib/api/meals.ts +++ b/src/lib/api/meals.ts @@ -1,25 +1,24 @@ import { apiPost, apiDelete, apiPatch } from './client'; import type { Meal, Preset } from '$lib/types/api'; -export function createMeal(diaryId: number, name?: string): Promise { - return apiPost('/meal', { diary_id: diaryId, ...(name ? { name } : {}) }); +export function createMeal(date: string, name: string): Promise { + return apiPost(`/diary/${date}/meal`, { name }); } -export function renameMeal(id: number, name: string): Promise { - return apiPatch(`/meal/${id}`, { name }); +export function renameMeal(date: string, id: number, name: string): Promise { + return apiPatch(`/diary/${date}/meal/${id}`, { name }); } -export function deleteMeal(id: number): Promise { - return apiDelete(`/meal/${id}`); +export function deleteMeal(date: string, id: number): Promise { + return apiDelete(`/diary/${date}/meal/${id}`); } -export function saveMealAsPreset(mealId: number, name?: string): Promise { - return apiPost(`/meal/${mealId}/save`, name ? { name } : {}); +export function saveMealAsPreset(date: string, mealId: number, name?: string): Promise { + return apiPost(`/diary/${date}/meal/${mealId}/preset`, name ? { name } : {}); } -export function createMealFromPreset(diaryId: number, presetId: number, name?: string): Promise { - return apiPost('/meal/from_preset', { - diary_id: diaryId, +export function createMealFromPreset(date: string, presetId: number, name?: string): Promise { + return apiPost(`/diary/${date}/meal/from_preset`, { preset_id: presetId, ...(name ? { name } : {}) }); diff --git a/src/lib/api/presets.ts b/src/lib/api/presets.ts index 848a815..ecfc121 100644 --- a/src/lib/api/presets.ts +++ b/src/lib/api/presets.ts @@ -1,13 +1,9 @@ import { apiGet, apiDelete } from './client'; -import type { PresetList, PresetDetails } from '$lib/types/api'; +import type { Preset } from '$lib/types/api'; -export function listPresets(q = '', limit = 20, offset = 0): Promise { - const params = new URLSearchParams({ q, limit: String(limit), offset: String(offset) }); - return apiGet(`/preset?${params}`); -} - -export function getPreset(id: number): Promise { - return apiGet(`/preset/${id}`); +export function listPresets(limit = 20, offset = 0): Promise { + const params = new URLSearchParams({ limit: String(limit), offset: String(offset) }); + return apiGet(`/preset?${params}`); } export function deletePreset(id: number): Promise { diff --git a/src/lib/api/products.ts b/src/lib/api/products.ts index 1efbaca..35c0dfe 100644 --- a/src/lib/api/products.ts +++ b/src/lib/api/products.ts @@ -1,9 +1,9 @@ import { apiGet, apiPost } from './client'; -import type { Product, ProductList } from '$lib/types/api'; +import type { Product } from '$lib/types/api'; -export function listProducts(q = '', limit = 20, offset = 0): Promise { +export function listProducts(q = '', limit = 20, offset = 0): Promise { const params = new URLSearchParams({ q, limit: String(limit), offset: String(offset) }); - return apiGet(`/product?${params}`); + return apiGet(`/product?${params}`); } export function createProduct(data: { @@ -18,5 +18,5 @@ export function createProduct(data: { } export function getProductByBarcode(barcode: string): Promise { - return apiGet(`/product/by_barcode?barcode=${encodeURIComponent(barcode)}`); + return apiGet(`/product/barcode/${encodeURIComponent(barcode)}`); } diff --git a/src/lib/api/settings.ts b/src/lib/api/settings.ts new file mode 100644 index 0000000..0c91861 --- /dev/null +++ b/src/lib/api/settings.ts @@ -0,0 +1,10 @@ +import { apiGet, apiPatch } from './client'; +import type { UserSettings } from '$lib/types/api'; + +export function getUserSettings(): Promise { + return apiGet('/user/settings'); +} + +export function updateUserSettings(patch: Omit, 'id' | 'calories_goal'> & { calories_goal?: number | null }): Promise { + return apiPatch('/user/settings', patch); +} diff --git a/src/lib/components/diary/MacroSummary.svelte b/src/lib/components/diary/MacroSummary.svelte index 405e7d6..7fa9260 100644 --- a/src/lib/components/diary/MacroSummary.svelte +++ b/src/lib/components/diary/MacroSummary.svelte @@ -1,17 +1,71 @@
@@ -29,32 +83,92 @@ />
- {kcal(macros.calories)} - kcal + {kcal(diary.calories)} + + {diary.calories_goal > 0 ? `/ ${kcal(diary.calories_goal)}` : 'kcal'} +
-
- {#each [ - { label: 'Protein', value: g(macros.protein), color: 'bg-blue-500', max: 150 }, - { label: 'Carbs', value: g(macros.carb), color: 'bg-yellow-500', max: 300 }, - { label: 'Fat', value: g(macros.fat), color: 'bg-orange-500', max: 100 }, - { label: 'Fiber', value: g(macros.fiber), color: 'bg-green-500', max: 40 } - ] as macro} +
+ {#each macroRows as macro}
{macro.label} - {macro.value}g + + {macro.value}g{macro.goal > 0 ? ` / ${macro.goal}g` : ''} +
{/each}
+ + +
+ + sheetOpen = false} title="Day goals"> +
+ +
+ + { + 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 placeholder-zinc-600 focus:outline-none focus:border-green-500 transition-colors" + /> +

Leave empty 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} + + +
+
diff --git a/src/lib/components/diary/MealCard.svelte b/src/lib/components/diary/MealCard.svelte index 8fe4c1d..99d2a49 100644 --- a/src/lib/components/diary/MealCard.svelte +++ b/src/lib/components/diary/MealCard.svelte @@ -11,10 +11,9 @@ interface Props { meal: Meal; date: string; - diaryId: number; } - let { meal, date, diaryId }: Props = $props(); + let { meal, date }: Props = $props(); const queryClient = useQueryClient(); let collapsed = $state(false); @@ -33,7 +32,7 @@ if (!renameName.trim()) return; renaming = true; try { - await renameMeal(meal.id, renameName.trim()); + await renameMeal(date, meal.id, renameName.trim()); queryClient.invalidateQueries({ queryKey: ['diary', date] }); renameOpen = false; } finally { @@ -43,14 +42,14 @@ async function handleDeleteMeal() { if (!confirm(`Delete meal "${meal.name}"?`)) return; - await deleteMeal(meal.id); + await deleteMeal(date, meal.id); queryClient.invalidateQueries({ queryKey: ['diary', date] }); } async function handleSavePreset() { saving = true; try { - await saveMealAsPreset(meal.id); + await saveMealAsPreset(date, meal.id); await new Promise(r => setTimeout(r, 600)); } finally { saving = false; @@ -58,7 +57,7 @@ } async function handleDeleteEntry(entryId: number) { - await deleteEntry(entryId); + await deleteEntry(date, meal.id, entryId); queryClient.invalidateQueries({ queryKey: ['diary', date] }); } diff --git a/src/lib/offline/mutations.ts b/src/lib/offline/mutations.ts index a47445c..4d8d954 100644 --- a/src/lib/offline/mutations.ts +++ b/src/lib/offline/mutations.ts @@ -55,7 +55,7 @@ export async function offlineAddEntry( grams: number ): Promise { if (navigator.onLine) { - await createEntry(mealId, product.id, grams); + await createEntry(date, mealId, product.id, grams); await queryClient.invalidateQueries({ queryKey: ['diary', date] }); return; } @@ -64,13 +64,13 @@ export async function offlineAddEntry( 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 } }); + 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: plainProduct, meal_id: mealId, ...macros }; + 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; @@ -96,8 +96,11 @@ export async function offlineEditEntry( 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; + if (navigator.onLine) { - await updateEntry(entryId, { grams: newGrams }); + await updateEntry(date, mealId!, entryId, { grams: newGrams }); await queryClient.invalidateQueries({ queryKey: ['diary', date] }); return; } @@ -106,7 +109,7 @@ export async function offlineEditEntry( throw new Error('Cannot edit an unsaved entry — please sync first.'); } - await enqueueMutation({ method: 'PATCH', url: `/entry/${entryId}`, body: { grams: newGrams } }); + await enqueueMutation({ method: 'PATCH', url: `/diary/${date}/meal/${mealId}/entry/${entryId}`, body: { grams: newGrams } }); network.incrementPending(); queryClient.setQueryData(['diary', date], diary => { @@ -145,16 +148,15 @@ export async function offlineEditEntry( export async function offlineAddMeal( queryClient: QueryClient, date: string, - diaryId: number, name: string ): Promise { if (navigator.onLine) { - await createMeal(diaryId, name || undefined); + await createMeal(date, name || 'Meal'); await queryClient.invalidateQueries({ queryKey: ['diary', date] }); return; } - await enqueueMutation({ method: 'POST', url: '/meal', body: { diary_id: diaryId, name: name || undefined } }); + await enqueueMutation({ method: 'POST', url: `/diary/${date}/meal`, body: { name: name || 'Meal' } }); network.incrementPending(); const diary = queryClient.getQueryData(['diary', date]); @@ -163,7 +165,7 @@ export async function offlineAddMeal( id: -Date.now(), name: name || `Meal ${order}`, order, - diary_id: diaryId, + diary_id: diary?.id ?? 0, entries: [], calories: 0, protein: 0, carb: 0, fat: 0, fiber: 0, }; diff --git a/src/lib/types/api.ts b/src/lib/types/api.ts index 77347d6..6060dea 100644 --- a/src/lib/types/api.ts +++ b/src/lib/types/api.ts @@ -25,12 +25,13 @@ export interface Product { fat: number; fiber: number; barcode: string | null; - usage_count_cached: number | null; + usage_count_cached?: number | null; } export interface Entry { id: number; grams: number; + product_id: number; product: Product; meal_id: number; calories: number; @@ -56,6 +57,11 @@ export interface Meal { 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; @@ -67,20 +73,29 @@ export interface Diary { export interface Preset { id: number; name: string; + user_id: number; calories: number; protein: number; carb: number; fat: number; fiber: number; + entries: PresetEntry[]; } -export interface PresetDetails extends Preset { - preset_entries: PresetEntry[]; +export interface UserSettings { + 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; @@ -88,11 +103,3 @@ export interface PresetEntry { fat: number; fiber: number; } - -export interface ProductList { - products: Product[]; -} - -export interface PresetList { - presets: Preset[]; -} diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte index 5418be6..4fdb76a 100644 --- a/src/routes/(app)/+layout.svelte +++ b/src/routes/(app)/+layout.svelte @@ -46,6 +46,7 @@ import { page } from '$app/state'; const isDiary = $derived(page.url.pathname.startsWith('/diary')); const isPresets = $derived(page.url.pathname.startsWith('/presets')); + const isSettings = $derived(page.url.pathname.startsWith('/settings')); {#if auth.isAuthenticated} @@ -98,6 +99,16 @@ import { page } from '$app/state'; Presets + + + + + Settings + + {: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}
- {:else if productsQuery.data?.products.length === 0} + {:else if productsQuery.data?.length === 0}
{#if !network.online}

No cached products match "{q}"

@@ -187,7 +186,7 @@
{:else}
    - {#each productsQuery.data?.products ?? [] as product (product.id)} + {#each productsQuery.data ?? [] as product (product.id)}
- {:else if presetsQuery.data?.presets.length === 0} + {: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?.presets ?? [] as preset (preset.id)} + {#each (presetsQuery.data ?? []).filter(p => !presetDebounced || p.name.toLowerCase().includes(presetDebounced.toLowerCase())) as preset (preset.id)}
  • + + {/if} + + diff --git a/src/routes/(auth)/register/+page.svelte b/src/routes/(auth)/register/+page.svelte index e62109b..602a431 100644 --- a/src/routes/(auth)/register/+page.svelte +++ b/src/routes/(auth)/register/+page.svelte @@ -1,13 +1,35 @@