[api] updated

This commit is contained in:
Piotr Domański 2026-04-07 22:39:28 +02:00
parent 578e8089c7
commit eece92f1e6
22 changed files with 491 additions and 121 deletions

1
.env.example Normal file
View file

@ -0,0 +1 @@
PUBLIC_TURNSTILE_SITE_KEY=your_site_key_here

View file

@ -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"

View file

@ -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<void> {
auth.setTokens(token.access_token, token.refresh_token);
}
export async function register(username: string, password: string): Promise<User> {
export async function register(username: string, password: string, captchaToken: string): Promise<void> {
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<boolean> {

View file

@ -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<Diary> {
return apiGet<Diary>(`/diary?date=${date}`);
export async function getDiary(date: string): Promise<Diary | null> {
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<Diary> {
return apiPost<Diary>('/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<Diary> {
return apiPatch<Diary>(`/diary/${date}`, patch);
}

View file

@ -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<Entry> {
return apiPost<Entry>('/entry', { meal_id: mealId, product_id: productId, grams });
export function createEntry(date: string, mealId: number, productId: number, grams: number): Promise<Entry> {
return apiPost<Entry>(`/diary/${date}/meal/${mealId}/entry`, { product_id: productId, grams });
}
export function updateEntry(id: number, patch: { grams?: number; product_id?: number; meal_id?: number }): Promise<Entry> {
return apiPatch<Entry>(`/entry/${id}`, patch);
export function updateEntry(date: string, mealId: number, id: number, patch: { grams?: number }): Promise<Entry> {
return apiPatch<Entry>(`/diary/${date}/meal/${mealId}/entry/${id}`, patch);
}
export function deleteEntry(id: number): Promise<void> {
return apiDelete(`/entry/${id}`);
export function deleteEntry(date: string, mealId: number, id: number): Promise<void> {
return apiDelete(`/diary/${date}/meal/${mealId}/entry/${id}`);
}

View file

@ -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<Meal> {
return apiPost<Meal>('/meal', { diary_id: diaryId, ...(name ? { name } : {}) });
export function createMeal(date: string, name: string): Promise<Meal> {
return apiPost<Meal>(`/diary/${date}/meal`, { name });
}
export function renameMeal(id: number, name: string): Promise<Meal> {
return apiPatch<Meal>(`/meal/${id}`, { name });
export function renameMeal(date: string, id: number, name: string): Promise<Meal> {
return apiPatch<Meal>(`/diary/${date}/meal/${id}`, { name });
}
export function deleteMeal(id: number): Promise<void> {
return apiDelete(`/meal/${id}`);
export function deleteMeal(date: string, id: number): Promise<void> {
return apiDelete(`/diary/${date}/meal/${id}`);
}
export function saveMealAsPreset(mealId: number, name?: string): Promise<Preset> {
return apiPost<Preset>(`/meal/${mealId}/save`, name ? { name } : {});
export function saveMealAsPreset(date: string, mealId: number, name?: string): Promise<Preset> {
return apiPost<Preset>(`/diary/${date}/meal/${mealId}/preset`, name ? { name } : {});
}
export function createMealFromPreset(diaryId: number, presetId: number, name?: string): Promise<Meal> {
return apiPost<Meal>('/meal/from_preset', {
diary_id: diaryId,
export function createMealFromPreset(date: string, presetId: number, name?: string): Promise<Meal> {
return apiPost<Meal>(`/diary/${date}/meal/from_preset`, {
preset_id: presetId,
...(name ? { name } : {})
});

View file

@ -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<PresetList> {
const params = new URLSearchParams({ q, limit: String(limit), offset: String(offset) });
return apiGet<PresetList>(`/preset?${params}`);
}
export function getPreset(id: number): Promise<PresetDetails> {
return apiGet<PresetDetails>(`/preset/${id}`);
export function listPresets(limit = 20, offset = 0): Promise<Preset[]> {
const params = new URLSearchParams({ limit: String(limit), offset: String(offset) });
return apiGet<Preset[]>(`/preset?${params}`);
}
export function deletePreset(id: number): Promise<void> {

View file

@ -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<ProductList> {
export function listProducts(q = '', limit = 20, offset = 0): Promise<Product[]> {
const params = new URLSearchParams({ q, limit: String(limit), offset: String(offset) });
return apiGet<ProductList>(`/product?${params}`);
return apiGet<Product[]>(`/product?${params}`);
}
export function createProduct(data: {
@ -18,5 +18,5 @@ export function createProduct(data: {
}
export function getProductByBarcode(barcode: string): Promise<Product> {
return apiGet<Product>(`/product/by_barcode?barcode=${encodeURIComponent(barcode)}`);
return apiGet<Product>(`/product/barcode/${encodeURIComponent(barcode)}`);
}

10
src/lib/api/settings.ts Normal file
View file

@ -0,0 +1,10 @@
import { apiGet, apiPatch } from './client';
import type { UserSettings } from '$lib/types/api';
export function getUserSettings(): Promise<UserSettings> {
return apiGet<UserSettings>('/user/settings');
}
export function updateUserSettings(patch: Omit<Partial<UserSettings>, 'id' | 'calories_goal'> & { calories_goal?: number | null }): Promise<UserSettings> {
return apiPatch<UserSettings>('/user/settings', patch);
}

View file

@ -1,17 +1,71 @@
<script lang="ts">
import type { Macros } from '$lib/types/api';
import type { Diary } from '$lib/types/api';
import { kcal, g } from '$lib/utils/format';
import { updateDiary } from '$lib/api/diary';
import { useQueryClient } from '@tanstack/svelte-query';
import Sheet from '$lib/components/ui/Sheet.svelte';
interface Props {
macros: Macros;
diary: Diary;
date: string;
}
let { macros }: Props = $props();
let { diary, date }: Props = $props();
const CALORIE_GOAL = 2000;
const pct = $derived(Math.min(100, Math.round((macros.calories / CALORIE_GOAL) * 100)));
const queryClient = useQueryClient();
// Calorie ring
const pct = $derived(diary.calories_goal > 0
? Math.min(100, Math.round((diary.calories / diary.calories_goal) * 100))
: 0);
const circumference = 2 * Math.PI * 40;
const dash = $derived(circumference * (pct / 100));
const macroRows = $derived([
{ label: 'Protein', value: g(diary.protein), goal: diary.protein_goal, color: 'bg-blue-500' },
{ label: 'Carbs', value: g(diary.carb), goal: diary.carb_goal, color: 'bg-yellow-500' },
{ label: 'Fat', value: g(diary.fat), goal: diary.fat_goal, color: 'bg-orange-500' },
{ label: 'Fiber', value: g(diary.fiber), goal: diary.fiber_goal, color: 'bg-green-500' },
]);
// Goal editing sheet
let sheetOpen = $state(false);
let saving = $state(false);
let form = $state<{
calories_goal: number | null;
protein_goal: number;
carb_goal: number;
fat_goal: number;
fiber_goal: number;
}>({
calories_goal: diary.calories_goal || null,
protein_goal: diary.protein_goal,
carb_goal: diary.carb_goal,
fat_goal: diary.fat_goal,
fiber_goal: diary.fiber_goal,
});
$effect(() => {
form = {
calories_goal: diary.calories_goal || null,
protein_goal: diary.protein_goal,
carb_goal: diary.carb_goal,
fat_goal: diary.fat_goal,
fiber_goal: diary.fiber_goal,
};
});
async function handleSave(e: SubmitEvent) {
e.preventDefault();
saving = true;
try {
await updateDiary(date, form);
await queryClient.invalidateQueries({ queryKey: ['diary', date] });
sheetOpen = false;
} finally {
saving = false;
}
}
</script>
<div class="bg-zinc-900 rounded-2xl p-4">
@ -29,32 +83,92 @@
/>
</svg>
<div class="absolute inset-0 flex flex-col items-center justify-center">
<span class="text-xl font-bold leading-none">{kcal(macros.calories)}</span>
<span class="text-xs text-zinc-500">kcal</span>
<span class="text-xl font-bold leading-none">{kcal(diary.calories)}</span>
<span class="text-xs text-zinc-500">
{diary.calories_goal > 0 ? `/ ${kcal(diary.calories_goal)}` : 'kcal'}
</span>
</div>
</div>
<!-- Macro bars -->
<div class="flex-1 space-y-2">
{#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}
<div class="flex-1 space-y-2 min-w-0">
{#each macroRows as macro}
<div>
<div class="flex justify-between text-xs text-zinc-400 mb-1">
<span>{macro.label}</span>
<span class="font-medium text-zinc-200">{macro.value}g</span>
<span class="font-medium text-zinc-200">
{macro.value}g{macro.goal > 0 ? ` / ${macro.goal}g` : ''}
</span>
</div>
<div class="h-1.5 bg-zinc-800 rounded-full overflow-hidden">
<div
class="h-full {macro.color} rounded-full transition-all duration-500"
style="width: {Math.min(100, (macro.value / macro.max) * 100)}%"
style="width: {macro.goal > 0 ? Math.min(100, (macro.value / macro.goal) * 100) : 0}%"
></div>
</div>
</div>
{/each}
</div>
<!-- Edit goals button -->
<button
onclick={() => sheetOpen = true}
class="self-start shrink-0 w-7 h-7 flex items-center justify-center rounded-full text-zinc-600 hover:text-zinc-300 hover:bg-zinc-800 transition-colors"
aria-label="Edit day goals"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
</div>
</div>
<Sheet open={sheetOpen} onclose={() => sheetOpen = false} title="Day goals">
<form onsubmit={handleSave} class="space-y-4">
<!-- Calories: nullable — empty = auto-calculated by server from macros -->
<div>
<label class="block text-sm text-zinc-400 mb-1.5">
Calories <span class="text-zinc-600">(kcal)</span>
</label>
<input
type="number"
min="0"
step="1"
placeholder="Auto"
value={form.calories_goal ?? ''}
oninput={(e) => {
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"
/>
<p class="text-xs text-zinc-600 mt-1">Leave empty to auto-calculate from macro goals</p>
</div>
{#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}
<div>
<label class="block text-sm text-zinc-400 mb-1.5">{field.label} <span class="text-zinc-600">({field.unit})</span></label>
<input
type="number"
min="0"
step="1"
bind:value={form[field.key]}
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"
/>
</div>
{/each}
<button
type="submit"
disabled={saving}
class="w-full bg-green-600 hover:bg-green-500 disabled:opacity-50 rounded-xl py-3 font-semibold transition-colors"
>
{saving ? 'Saving…' : 'Save'}
</button>
</form>
</Sheet>

View file

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

View file

@ -55,7 +55,7 @@ export async function offlineAddEntry(
grams: number
): Promise<void> {
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>(['diary', date], diary => {
if (!diary) return diary;
@ -96,8 +96,11 @@ export async function offlineEditEntry(
entryId: number,
newGrams: number
): Promise<void> {
const diary = queryClient.getQueryData<Diary>(['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>(['diary', date], diary => {
@ -145,16 +148,15 @@ export async function offlineEditEntry(
export async function offlineAddMeal(
queryClient: QueryClient,
date: string,
diaryId: number,
name: string
): Promise<void> {
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>(['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,
};

View file

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

View file

@ -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'));
</script>
{#if auth.isAuthenticated}
@ -98,6 +99,16 @@ import { page } from '$app/state';
</svg>
Presets
</a>
<a
href="/settings"
class="flex items-center gap-2.5 px-3 py-2 rounded-xl text-sm font-medium transition-colors
{isSettings ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-900'}"
>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Settings
</a>
</nav>
<button
@ -138,6 +149,16 @@ import { page } from '$app/state';
</svg>
Presets
</a>
<a
href="/settings"
class="flex-1 flex flex-col items-center gap-1 py-3 text-xs font-medium transition-colors
{isSettings ? 'text-green-400' : 'text-zinc-500 hover:text-zinc-300'}"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Settings
</a>
<button
onclick={handleLogout}
class="flex-1 flex flex-col items-center gap-1 py-3 text-xs font-medium text-zinc-500 hover:text-zinc-300 transition-colors"

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { page } from '$app/state';
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
import { getDiary } from '$lib/api/diary';
import { getDiary, createDiary } from '$lib/api/diary';
import { getCachedDiary, cacheDiary } from '$lib/offline/db';
import { addDays, formatDisplay, today } from '$lib/utils/date';
import { goto } from '$app/navigation';
@ -14,11 +14,12 @@
import CommandPalette from '$lib/components/ui/CommandPalette.svelte';
import type { Command } from '$lib/components/ui/CommandPalette.svelte';
const date = $derived(page.params.date);
const date = $derived(page.params.date!);
const queryClient = useQueryClient();
let calendarOpen = $state(false);
let commandOpen = $state(false);
let creating = $state(false);
const commands = $derived<Command[]>([
...(diaryQuery.data?.meals ?? []).map(meal => ({
@ -31,7 +32,7 @@
id: 'add-meal',
label: 'Add meal',
keywords: ['m', 'meal'],
action: () => goto(`/diary/${date}/add-meal?diary_id=${diaryQuery.data?.id}`)
action: () => goto(`/diary/${date}/add-meal`)
}
]);
@ -64,11 +65,29 @@
// Online: fetch fresh data — this overwrites both TQ cache and IndexedDB,
// replacing any temporary negative-ID entries from optimistic updates
const diary = await getDiary(date);
if (diary === null) {
if (date === today()) {
const created = await createDiary(date);
await cacheDiary(JSON.parse(JSON.stringify(created)));
return created;
}
return null;
}
await cacheDiary(JSON.parse(JSON.stringify(diary)));
return diary;
}
}));
async function handleCreate() {
creating = true;
try {
await createDiary(date);
queryClient.invalidateQueries({ queryKey: ['diary', date] });
} finally {
creating = false;
}
}
function goDate(delta: number) {
goto(`/diary/${addDays(date, delta)}`);
}
@ -114,24 +133,36 @@
Retry
</button>
</div>
{:else if diaryQuery.data === null}
<div class="text-center text-zinc-500 mt-20">
<p class="text-lg">No diary for {formatDisplay(date)}</p>
<p class="text-sm mt-1">No entries have been tracked for this date.</p>
<button
onclick={handleCreate}
disabled={creating}
class="mt-4 px-5 py-2.5 bg-green-600 hover:bg-green-500 disabled:opacity-50 text-white rounded-xl text-sm font-medium transition-colors"
>
{creating ? 'Creating…' : 'Create diary'}
</button>
</div>
{:else if diaryQuery.data}
{@const diary = diaryQuery.data}
<div class="space-y-4 lg:grid lg:grid-cols-[300px_1fr] lg:gap-6 lg:space-y-0 lg:items-start">
<!-- Left: macros summary (sticky on desktop) -->
<div class="lg:sticky lg:top-[4.5rem]">
<MacroSummary macros={diary} />
<MacroSummary {diary} {date} />
</div>
<!-- Right: meals -->
<div class="space-y-4">
{#each diary.meals as meal (meal.id)}
<MealCard {meal} {date} diaryId={diary.id} />
<MealCard {meal} {date} />
{/each}
<!-- Add meal button -->
<button
onclick={() => goto(`/diary/${date}/add-meal?diary_id=${diaryQuery.data?.id}`)}
onclick={() => goto(`/diary/${date}/add-meal`)}
class="w-full border border-dashed border-zinc-700 rounded-2xl py-4 text-zinc-500 hover:text-zinc-300 hover:border-zinc-500 transition-colors text-sm font-medium"
>
+ Add meal
@ -142,7 +173,7 @@
</main>
<!-- FAB: Add entry (mobile only) -->
{#if diaryQuery.data}
{#if diaryQuery.data != null}
<button
onclick={() => {
const firstMeal = diaryQuery.data?.meals[0];

View file

@ -12,7 +12,7 @@
import BarcodeScanner from '$lib/components/ui/BarcodeScanner.svelte';
import { kcal, g } from '$lib/utils/format';
const date = $derived(page.params.date);
const date = $derived(page.params.date!);
const mealId = $derived(Number(page.url.searchParams.get('meal_id')));
const queryClient = useQueryClient();
@ -49,13 +49,12 @@
queryKey: ['products', debouncedQ],
queryFn: async () => {
if (!network.online) {
const products = await searchCachedProducts(debouncedQ);
return { products };
return searchCachedProducts(debouncedQ);
}
const result = await listProducts(debouncedQ, 30);
const products = await listProducts(debouncedQ, 30);
// Cache for offline use — fire and forget
cacheProducts(result.products);
return result;
cacheProducts(products);
return products;
},
staleTime: 0
}));
@ -130,7 +129,7 @@
oninput={(e) => handleSearch(e.currentTarget.value)}
onkeydown={(e) => {
if (e.key === 'Enter') {
const first = productsQuery.data?.products[0];
const first = productsQuery.data?.[0];
if (first) selectProduct(first);
}
}}
@ -172,7 +171,7 @@
<div class="h-14 bg-zinc-900 rounded-xl animate-pulse mb-2"></div>
{/each}
</div>
{:else if productsQuery.data?.products.length === 0}
{:else if productsQuery.data?.length === 0}
<div class="text-center text-zinc-500 mt-16 px-6">
{#if !network.online}
<p class="text-base">No cached products match "{q}"</p>
@ -187,7 +186,7 @@
</div>
{:else}
<ul class="divide-y divide-zinc-800/50">
{#each productsQuery.data?.products ?? [] as product (product.id)}
{#each productsQuery.data ?? [] as product (product.id)}
<li>
<button
onclick={() => selectProduct(product)}

View file

@ -10,8 +10,7 @@
import TopBar from '$lib/components/ui/TopBar.svelte';
import { kcal, g } from '$lib/utils/format';
const date = $derived(page.params.date);
const diaryId = $derived(Number(page.url.searchParams.get('diary_id')));
const date = $derived(page.params.date!);
const queryClient = useQueryClient();
type Tab = 'new' | 'preset';
@ -30,15 +29,15 @@
}
const presetsQuery = createQuery(() => ({
queryKey: ['presets', presetDebounced],
queryFn: () => listPresets(presetDebounced, 30)
queryKey: ['presets'],
queryFn: () => listPresets(30)
}));
async function handleCreateNew(e: SubmitEvent) {
e.preventDefault();
submitting = true;
try {
await offlineAddMeal(queryClient, date, diaryId, mealName);
await offlineAddMeal(queryClient, date, mealName);
goto(`/diary/${date}`);
} finally {
submitting = false;
@ -49,7 +48,7 @@
submitting = true;
error = '';
try {
await createMealFromPreset(diaryId, preset.id, preset.name);
await createMealFromPreset(date, preset.id, preset.name);
await queryClient.invalidateQueries({ queryKey: ['diary', date] });
goto(`/diary/${date}`);
} catch {
@ -123,14 +122,14 @@
<div class="h-16 bg-zinc-900 rounded-xl animate-pulse"></div>
{/each}
</div>
{:else if presetsQuery.data?.presets.length === 0}
{:else if (presetsQuery.data ?? []).filter(p => !presetDebounced || p.name.toLowerCase().includes(presetDebounced.toLowerCase())).length === 0}
<div class="text-center text-zinc-500 mt-16 px-6">
<p>No presets yet</p>
<p class="text-sm mt-1">Save a meal as preset from the diary view</p>
</div>
{:else}
<ul class="divide-y divide-zinc-800/50">
{#each presetsQuery.data?.presets ?? [] as preset (preset.id)}
{#each (presetsQuery.data ?? []).filter(p => !presetDebounced || p.name.toLowerCase().includes(presetDebounced.toLowerCase())) as preset (preset.id)}
<li>
<button
onclick={() => handleFromPreset(preset)}

View file

@ -7,7 +7,7 @@
import { network } from '$lib/offline/network.svelte';
import TopBar from '$lib/components/ui/TopBar.svelte';
const date = $derived(page.params.date);
const date = $derived(page.params.date!);
const entryId = $derived(Number(page.params.entry_id));
const queryClient = useQueryClient();
@ -52,7 +52,7 @@
if (!confirm('Remove this entry?')) return;
deleting = true;
try {
await deleteEntry(entryId);
await deleteEntry(date, entry!.meal_id, entryId);
await queryClient.invalidateQueries({ queryKey: ['diary', date] });
goto(`/diary/${date}`);
} finally {

View file

@ -1,6 +1,6 @@
<script lang="ts">
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
import { getDiary } from '$lib/api/diary';
import { getDiary, createDiary } from '$lib/api/diary';
import { getCachedDiary, cacheDiary } from '$lib/offline/db';
import { addDays, formatDisplay, today } from '$lib/utils/date';
import { goto } from '$app/navigation';
@ -30,7 +30,7 @@
id: 'add-meal',
label: 'Add meal',
keywords: ['m', 'meal'],
action: () => goto(`/diary/${date}/add-meal?diary_id=${diaryQuery.data?.id}`)
action: () => goto(`/diary/${date}/add-meal`)
}
]);
@ -59,6 +59,11 @@
throw new Error('Offline and no cached data');
}
const diary = await getDiary(date);
if (diary === null) {
const created = await createDiary(date);
await cacheDiary(JSON.parse(JSON.stringify(created)));
return created;
}
await cacheDiary(JSON.parse(JSON.stringify(diary)));
return diary;
}
@ -115,18 +120,18 @@
<div class="space-y-4 lg:grid lg:grid-cols-[300px_1fr] lg:gap-6 lg:space-y-0 lg:items-start">
<!-- Left: macros summary (sticky on desktop) -->
<div class="lg:sticky lg:top-[4.5rem]">
<MacroSummary macros={diary} />
<MacroSummary {diary} {date} />
</div>
<!-- Right: meals -->
<div class="space-y-4">
{#each diary.meals as meal (meal.id)}
<MealCard {meal} {date} diaryId={diary.id} />
<MealCard {meal} {date} />
{/each}
<!-- Add meal button -->
<button
onclick={() => goto(`/diary/${date}/add-meal?diary_id=${diaryQuery.data?.id}`)}
onclick={() => goto(`/diary/${date}/add-meal`)}
class="w-full border border-dashed border-zinc-700 rounded-2xl py-4 text-zinc-500 hover:text-zinc-300 hover:border-zinc-500 transition-colors text-sm font-medium"
>
+ Add meal
@ -137,7 +142,7 @@
</main>
<!-- FAB: Add entry (mobile only) -->
{#if diaryQuery.data}
{#if diaryQuery.data != null}
<button
onclick={() => {
const firstMeal = diaryQuery.data?.meals[0];

View file

@ -1,7 +1,6 @@
<script lang="ts">
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
import { listPresets, deletePreset } from '$lib/api/presets';
import { getDiary } from '$lib/api/diary';
import { createMealFromPreset } from '$lib/api/meals';
import { today } from '$lib/utils/date';
import { kcal, g } from '$lib/utils/format';
@ -24,15 +23,20 @@
}
const presetsQuery = createQuery(() => ({
queryKey: ['presets', debouncedQ],
queryFn: () => listPresets(debouncedQ, 50)
queryKey: ['presets'],
queryFn: () => listPresets(50)
}));
const filteredPresets = $derived(
(presetsQuery.data ?? []).filter(p =>
!debouncedQ || p.name.toLowerCase().includes(debouncedQ.toLowerCase())
)
);
async function handleAddToToday(preset: Preset) {
addingId = preset.id;
try {
const diary = await getDiary(today());
await createMealFromPreset(diary.id, preset.id, preset.name);
await createMealFromPreset(today(), preset.id, preset.name);
queryClient.invalidateQueries({ queryKey: ['diary', today()] });
addedId = preset.id;
setTimeout(() => { addedId = null; }, 1500);
@ -81,14 +85,14 @@
</div>
{:else if presetsQuery.isError}
<p class="text-center text-zinc-500 mt-16">Could not load presets</p>
{:else if !presetsQuery.data?.presets.length}
{:else if !filteredPresets.length}
<div class="text-center text-zinc-500 mt-16 px-6">
<p>No presets yet</p>
<p class="text-sm mt-1">Save a meal as preset from the diary view</p>
</div>
{:else}
<ul class="divide-y divide-zinc-800/50">
{#each presetsQuery.data.presets as preset (preset.id)}
{#each filteredPresets as preset (preset.id)}
<li class="flex items-center justify-between px-4 py-4">
<div class="min-w-0 flex-1">
<p class="font-medium truncate">{preset.name}</p>

View file

@ -0,0 +1,137 @@
<script lang="ts">
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
import { getUserSettings, updateUserSettings } from '$lib/api/settings';
import TopBar from '$lib/components/ui/TopBar.svelte';
import { today } from '$lib/utils/date';
const queryClient = useQueryClient();
const settingsQuery = createQuery(() => ({
queryKey: ['user-settings'],
queryFn: getUserSettings,
staleTime: 5 * 60 * 1000,
}));
let saving = $state(false);
let saved = $state(false);
let error = $state('');
let form = $state<{
calories_goal: number | null;
protein_goal: number;
carb_goal: number;
fat_goal: number;
fiber_goal: number;
}>({
calories_goal: null,
protein_goal: 0,
carb_goal: 0,
fat_goal: 0,
fiber_goal: 0,
});
$effect(() => {
if (settingsQuery.data) {
form = {
calories_goal: settingsQuery.data.calories_goal || null,
protein_goal: settingsQuery.data.protein_goal,
carb_goal: settingsQuery.data.carb_goal,
fat_goal: settingsQuery.data.fat_goal,
fiber_goal: settingsQuery.data.fiber_goal,
};
}
});
async function handleSave(e: SubmitEvent) {
e.preventDefault();
saving = true;
error = '';
try {
await updateUserSettings(form);
queryClient.invalidateQueries({ queryKey: ['user-settings'] });
saved = true;
setTimeout(() => { saved = false; }, 2000);
} catch {
error = 'Failed to save settings';
} finally {
saving = false;
}
}
</script>
<div class="flex flex-col h-screen">
<TopBar title="Settings" back="/diary/{today()}" />
<main class="flex-1 overflow-y-auto px-4 py-6">
{#if settingsQuery.isPending}
<div class="space-y-4">
{#each Array(5) as _}
<div class="h-16 bg-zinc-900 rounded-xl animate-pulse"></div>
{/each}
</div>
{:else if settingsQuery.isError}
<p class="text-center text-zinc-500 mt-16">Could not load settings</p>
{:else}
<form onsubmit={handleSave} class="space-y-6">
<section>
<h2 class="text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-3">Daily goals</h2>
<div class="space-y-3">
<!-- Calories: nullable — empty = auto-calculated by server from macros -->
<div class="bg-zinc-900 rounded-xl px-4 py-3 flex items-center gap-4">
<div class="flex-1">
<p class="text-sm font-medium">Calories</p>
<p class="text-xs text-zinc-500">kcal · leave empty to auto-calculate</p>
</div>
<input
type="number"
min="0"
step="1"
placeholder="Auto"
value={form.calories_goal ?? ''}
oninput={(e) => {
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 placeholder-zinc-600 focus:outline-none focus:border-green-500 transition-colors"
/>
</div>
{#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}
<div class="bg-zinc-900 rounded-xl px-4 py-3 flex items-center gap-4">
<div class="flex-1">
<p class="text-sm font-medium">{field.label}</p>
<p class="text-xs text-zinc-500">{field.unit} per day</p>
</div>
<input
type="number"
min="0"
step="1"
bind:value={form[field.key]}
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"
/>
</div>
{/each}
</div>
</section>
{#if error}
<p class="text-red-400 text-sm">{error}</p>
{/if}
<button
type="submit"
disabled={saving}
class="w-full bg-green-600 hover:bg-green-500 disabled:opacity-50 rounded-xl py-3 font-semibold transition-colors
{saved ? 'bg-zinc-700 hover:bg-zinc-700' : ''}"
>
{saving ? 'Saving…' : saved ? 'Saved!' : 'Save'}
</button>
</form>
{/if}
</main>
</div>

View file

@ -1,13 +1,35 @@
<script lang="ts">
import { register, login } from '$lib/api/auth';
import { register } from '$lib/api/auth';
import { goto } from '$app/navigation';
import { today } from '$lib/utils/date';
import { onMount } from 'svelte';
import { PUBLIC_TURNSTILE_SITE_KEY } from '$env/static/public';
let username = $state('');
let password = $state('');
let confirm = $state('');
let error = $state('');
let loading = $state(false);
let captchaToken = $state('');
let captchaContainer = $state<HTMLDivElement | null>(null);
onMount(() => {
const script = document.createElement('script');
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js';
script.async = true;
script.defer = true;
script.onload = () => {
if (captchaContainer) {
(window as any).turnstile.render(captchaContainer, {
sitekey: PUBLIC_TURNSTILE_SITE_KEY,
callback: (token: string) => { captchaToken = token; },
'expired-callback': () => { captchaToken = ''; },
});
}
};
document.head.appendChild(script);
return () => { document.head.removeChild(script); };
});
async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
@ -16,10 +38,13 @@
error = 'Passwords do not match';
return;
}
if (!captchaToken) {
error = 'Please complete the captcha';
return;
}
loading = true;
try {
await register(username, password);
await login(username, password);
await register(username, password, captchaToken);
goto(`/diary/${today()}`);
} catch (err: unknown) {
const e2 = err as { detail?: { detail?: string } };
@ -71,13 +96,15 @@
/>
</div>
<div bind:this={captchaContainer}></div>
{#if error}
<p class="text-red-400 text-sm">{error}</p>
{/if}
<button
type="submit"
disabled={loading}
disabled={loading || !captchaToken}
class="w-full bg-green-600 hover:bg-green-500 disabled:opacity-50 rounded-xl py-3 font-semibold transition-colors"
>
{loading ? 'Creating account…' : 'Create account'}