From 176a9fc3bbb16b82d8e7c82ccf61ff8cb22289c4 Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 25 May 2026 03:55:59 +0200 Subject: [PATCH] Make calorie logging reliable --- app/build.gradle | 4 +- web/android/app/build.gradle | 4 +- web/docker-compose.yml | 9 +--- web/src/App.svelte | 99 +++++++++++++++++++++++++++++++++--- web/src/MealForm.svelte | 12 +++-- 5 files changed, 105 insertions(+), 23 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 812b80c..1678d49 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,8 @@ android { applicationId 'com.danvics.calorieai' minSdk 26 targetSdk 35 - versionCode 4 - versionName '1.3' + versionCode 8 + versionName '1.8' } buildFeatures { diff --git a/web/android/app/build.gradle b/web/android/app/build.gradle index d0abd0e..3d9a48a 100644 --- a/web/android/app/build.gradle +++ b/web/android/app/build.gradle @@ -7,8 +7,8 @@ android { applicationId "com.danvics.calorieai" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 1 - versionName "1.0" + versionCode 8 + versionName "1.8" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/web/docker-compose.yml b/web/docker-compose.yml index 48ae5b0..5dd0b0a 100644 --- a/web/docker-compose.yml +++ b/web/docker-compose.yml @@ -28,16 +28,9 @@ services: - /home/danvics/docker/quiz/backend/.env environment: CALORIE_AI_DATABASE_URL: postgresql://calorie_ai@postgres:5432/calorie_ai - LITELLM_API_BASE: http://litellm:4000/v1 + LITELLM_API_BASE: https://llm.danvics.com/v1 LITELLM_MODEL: openrouter-claude-haiku-4.5 ports: - "127.0.0.1:8095:8080" volumes: - ./data:/app/data - networks: - - default - - litellm_default - -networks: - litellm_default: - external: true diff --git a/web/src/App.svelte b/web/src/App.svelte index 3a5ed4c..ff2e430 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -99,11 +99,15 @@ $: diaryDays = Array.from(new Set(diaryRows.map(entry => entry.date))).sort((a, b) => b.localeCompare(a)); onMount(async () => { - await loadSession(); if (location.pathname === '/verify' && resetToken) await verifyAccountToken(); + if (loginMode) return; + + await loadSession(); page = localStorage.getItem('calorie_ai_last_tab') || 'dashboard'; setDefaultMealDateTime(); - await Promise.all([loadSettingsFromServer(), loadEntriesFromServer(), loadActivitiesFromServer(), loadModels(), loadPlans()]); + await loadSettingsFromServer(); + await loadModels(); + await Promise.all([loadEntriesFromServer(), loadActivitiesFromServer(), loadPlans()]); }); function dateKey(date) { @@ -234,10 +238,17 @@ async function loadSettingsFromServer() { try { const response = await fetch('/api/settings'); - if (response.ok) settings = { ...defaultSettings, ...await response.json() }; + if (response.ok) settings = normalizeSettings(await response.json()); } catch {} } + function normalizeSettings(data = {}) { + const next = { ...defaultSettings, ...data }; + if (!next.visionModel) next.visionModel = defaultSettings.visionModel; + if (!next.taskModel) next.taskModel = defaultSettings.taskModel; + return next; + } + async function saveSettings() { try { await fetch('/api/settings', { @@ -314,6 +325,20 @@ return { mealName: '', calories: 0, proteinGrams: 0, carbsGrams: 0, fatGrams: 0, fruitServings: 0, vegetableServings: 0, foodGroups: '', notes: '' }; } + function normalizeEstimateDraft() { + return { + mealName: estimateDraft.mealName || description.trim() || 'Manual meal', + calories: Math.max(0, Math.round(Number(estimateDraft.calories) || 0)), + proteinGrams: Math.max(0, Math.round(Number(estimateDraft.proteinGrams) || 0)), + carbsGrams: Math.max(0, Math.round(Number(estimateDraft.carbsGrams) || 0)), + fatGrams: Math.max(0, Math.round(Number(estimateDraft.fatGrams) || 0)), + fruitServings: Math.max(0, Number(estimateDraft.fruitServings) || 0), + vegetableServings: Math.max(0, Number(estimateDraft.vegetableServings) || 0), + foodGroups: String(estimateDraft.foodGroups || ''), + notes: String(estimateDraft.notes || ''), + }; + } + async function saveSelectedModels() { await saveSettings(); modelStatus = 'Models saved.'; @@ -474,15 +499,32 @@ } async function chatCompletion(body) { + const payload = { ...body, model: selectedModel(body.model) }; const response = await fetch('/api/chat', { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ body }), + body: JSON.stringify({ body: payload }), }); const text = await response.text(); - if (!response.ok) throw new Error(text); + if (!response.ok) throw new Error(readApiError(text, 'AI request failed')); const json = JSON.parse(text); - return json.choices[0].message.content.trim(); + const content = json.choices?.[0]?.message?.content; + if (!content) throw new Error('AI returned an empty response.'); + return content.trim(); + } + + function selectedModel(preferred) { + if (preferred && models.some(model => model.id === preferred)) return preferred; + return models[0]?.id || fallbackModels[0].id; + } + + function readApiError(text, fallback) { + try { + const data = JSON.parse(text); + return data.error?.message || data.error || fallback; + } catch { + return text || fallback; + } } async function requestImageEstimate() { @@ -608,6 +650,7 @@ }); if (!response.ok) throw new Error((await response.json().catch(() => ({}))).error || 'Failed to save meal'); applyEntriesData(await response.json()); + estimateDraft = emptyEstimate(); description = ''; measure = ''; selectedImageDataUrl = ''; @@ -645,7 +688,7 @@ if (!existing) return; const updated = { ...existing, - ...estimateDraft, + ...normalizeEstimateDraft(), date: mealDate, time: mealTime, mealType, @@ -672,6 +715,47 @@ } } + async function saveManualMeal() { + if (!mealDate || !mealTime || !mealType) return setMealStatus('Choose a date, time, and meal type.', true); + if (!description.trim() && !String(estimateDraft.mealName || '').trim()) return setMealStatus('Describe the meal or enter a meal name.', true); + const estimate = normalizeEstimateDraft(); + if (!estimate.calories) return setMealStatus('Enter estimated calories before saving manually.', true); + savingMeal = true; + try { + const meal = { + ...estimate, + id: editingEntryId || entryId(), + date: mealDate, + time: mealTime, + mealType, + description, + measure, + imageIncluded: Boolean(selectedImageDataUrl), + imageName: selectedImageName, + updatedAt: new Date().toISOString(), + }; + const response = await fetch('/api/entries', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(meal), + }); + if (!response.ok) throw new Error((await response.json().catch(() => ({}))).error || 'Failed to save meal'); + applyEntriesData(await response.json()); + estimateDraft = emptyEstimate(); + description = ''; + measure = ''; + selectedImageDataUrl = ''; + selectedImageName = ''; + editingEntryId = ''; + setDefaultMealDateTime(); + setMealStatus(`Saved manually. ${estimate.mealName} ยท ${estimate.calories} kcal`); + } catch (error) { + setMealStatus(error.message, true); + } finally { + savingMeal = false; + } + } + function cancelEdit() { editingEntryId = ''; estimateDraft = emptyEstimate(); @@ -905,6 +989,7 @@ Include: daily calorie target, maintenance calories, protein target, meal loggin bind:estimateDraft {chooseImage} {saveMeal} + {saveManualMeal} {saveMealEdits} {cancelEdit} /> diff --git a/web/src/MealForm.svelte b/web/src/MealForm.svelte index 1324a64..4ae387e 100644 --- a/web/src/MealForm.svelte +++ b/web/src/MealForm.svelte @@ -15,6 +15,7 @@ export let estimateDraft; export let chooseImage; export let saveMeal; + export let saveManualMeal; export let saveMealEdits; export let cancelEdit; @@ -43,8 +44,11 @@ {#if selectedImageDataUrl}Selected meal preview{/if} - {#if editingEntryId} -
+
+
+

Nutrition estimate

+

AI fills these automatically, or you can enter calories and save manually.

+
@@ -54,10 +58,10 @@ -
- {/if} +
+ {#if editingEntryId}{/if} {#if status}

{status}

{/if}