Make calorie logging reliable
Some checks failed
Android CI / build (push) Successful in 3m0s
Web CI / test (push) Successful in 1m31s
Android Release / release-apk (push) Failing after 45s

This commit is contained in:
Daniel 2026-05-25 03:55:59 +02:00
parent 33d50258ce
commit 176a9fc3bb
5 changed files with 105 additions and 23 deletions

View file

@ -12,8 +12,8 @@ android {
applicationId 'com.danvics.calorieai' applicationId 'com.danvics.calorieai'
minSdk 26 minSdk 26
targetSdk 35 targetSdk 35
versionCode 4 versionCode 8
versionName '1.3' versionName '1.8'
} }
buildFeatures { buildFeatures {

View file

@ -7,8 +7,8 @@ android {
applicationId "com.danvics.calorieai" applicationId "com.danvics.calorieai"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1 versionCode 8
versionName "1.0" versionName "1.8"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

View file

@ -28,16 +28,9 @@ services:
- /home/danvics/docker/quiz/backend/.env - /home/danvics/docker/quiz/backend/.env
environment: environment:
CALORIE_AI_DATABASE_URL: postgresql://calorie_ai@postgres:5432/calorie_ai 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 LITELLM_MODEL: openrouter-claude-haiku-4.5
ports: ports:
- "127.0.0.1:8095:8080" - "127.0.0.1:8095:8080"
volumes: volumes:
- ./data:/app/data - ./data:/app/data
networks:
- default
- litellm_default
networks:
litellm_default:
external: true

View file

@ -99,11 +99,15 @@
$: diaryDays = Array.from(new Set(diaryRows.map(entry => entry.date))).sort((a, b) => b.localeCompare(a)); $: diaryDays = Array.from(new Set(diaryRows.map(entry => entry.date))).sort((a, b) => b.localeCompare(a));
onMount(async () => { onMount(async () => {
await loadSession();
if (location.pathname === '/verify' && resetToken) await verifyAccountToken(); if (location.pathname === '/verify' && resetToken) await verifyAccountToken();
if (loginMode) return;
await loadSession();
page = localStorage.getItem('calorie_ai_last_tab') || 'dashboard'; page = localStorage.getItem('calorie_ai_last_tab') || 'dashboard';
setDefaultMealDateTime(); setDefaultMealDateTime();
await Promise.all([loadSettingsFromServer(), loadEntriesFromServer(), loadActivitiesFromServer(), loadModels(), loadPlans()]); await loadSettingsFromServer();
await loadModels();
await Promise.all([loadEntriesFromServer(), loadActivitiesFromServer(), loadPlans()]);
}); });
function dateKey(date) { function dateKey(date) {
@ -234,10 +238,17 @@
async function loadSettingsFromServer() { async function loadSettingsFromServer() {
try { try {
const response = await fetch('/api/settings'); const response = await fetch('/api/settings');
if (response.ok) settings = { ...defaultSettings, ...await response.json() }; if (response.ok) settings = normalizeSettings(await response.json());
} catch {} } 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() { async function saveSettings() {
try { try {
await fetch('/api/settings', { await fetch('/api/settings', {
@ -314,6 +325,20 @@
return { mealName: '', calories: 0, proteinGrams: 0, carbsGrams: 0, fatGrams: 0, fruitServings: 0, vegetableServings: 0, foodGroups: '', notes: '' }; 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() { async function saveSelectedModels() {
await saveSettings(); await saveSettings();
modelStatus = 'Models saved.'; modelStatus = 'Models saved.';
@ -474,15 +499,32 @@
} }
async function chatCompletion(body) { async function chatCompletion(body) {
const payload = { ...body, model: selectedModel(body.model) };
const response = await fetch('/api/chat', { const response = await fetch('/api/chat', {
method: 'POST', method: 'POST',
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
body: JSON.stringify({ body }), body: JSON.stringify({ body: payload }),
}); });
const text = await response.text(); 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); 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() { async function requestImageEstimate() {
@ -608,6 +650,7 @@
}); });
if (!response.ok) throw new Error((await response.json().catch(() => ({}))).error || 'Failed to save meal'); if (!response.ok) throw new Error((await response.json().catch(() => ({}))).error || 'Failed to save meal');
applyEntriesData(await response.json()); applyEntriesData(await response.json());
estimateDraft = emptyEstimate();
description = ''; description = '';
measure = ''; measure = '';
selectedImageDataUrl = ''; selectedImageDataUrl = '';
@ -645,7 +688,7 @@
if (!existing) return; if (!existing) return;
const updated = { const updated = {
...existing, ...existing,
...estimateDraft, ...normalizeEstimateDraft(),
date: mealDate, date: mealDate,
time: mealTime, time: mealTime,
mealType, 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() { function cancelEdit() {
editingEntryId = ''; editingEntryId = '';
estimateDraft = emptyEstimate(); estimateDraft = emptyEstimate();
@ -905,6 +989,7 @@ Include: daily calorie target, maintenance calories, protein target, meal loggin
bind:estimateDraft bind:estimateDraft
{chooseImage} {chooseImage}
{saveMeal} {saveMeal}
{saveManualMeal}
{saveMealEdits} {saveMealEdits}
{cancelEdit} {cancelEdit}
/> />

View file

@ -15,6 +15,7 @@
export let estimateDraft; export let estimateDraft;
export let chooseImage; export let chooseImage;
export let saveMeal; export let saveMeal;
export let saveManualMeal;
export let saveMealEdits; export let saveMealEdits;
export let cancelEdit; export let cancelEdit;
</script> </script>
@ -43,8 +44,11 @@
</div> </div>
</div> </div>
{#if selectedImageDataUrl}<img id="preview" class="mx-4 mb-4 max-h-80 w-[calc(100%-2rem)] rounded-2xl object-cover" src={selectedImageDataUrl} alt="Selected meal preview">{/if} {#if selectedImageDataUrl}<img id="preview" class="mx-4 mb-4 max-h-80 w-[calc(100%-2rem)] rounded-2xl object-cover" src={selectedImageDataUrl} alt="Selected meal preview">{/if}
{#if editingEntryId} <div class="grid gap-3 border-t border-slate-200 p-4 md:grid-cols-4">
<div class="grid gap-3 border-t border-slate-200 p-4 md:grid-cols-4"> <div class="md:col-span-4">
<h3 class="text-sm font-black uppercase tracking-wide text-slate-500">Nutrition estimate</h3>
<p class="mt-1 text-sm text-slate-500">AI fills these automatically, or you can enter calories and save manually.</p>
</div>
<Field className="md:col-span-2" label="Meal name"><input class="input" bind:value={estimateDraft.mealName}></Field> <Field className="md:col-span-2" label="Meal name"><input class="input" bind:value={estimateDraft.mealName}></Field>
<Field label="Calories"><input class="input" type="number" min="0" bind:value={estimateDraft.calories}></Field> <Field label="Calories"><input class="input" type="number" min="0" bind:value={estimateDraft.calories}></Field>
<Field label="Protein (g)"><input class="input" type="number" min="0" bind:value={estimateDraft.proteinGrams}></Field> <Field label="Protein (g)"><input class="input" type="number" min="0" bind:value={estimateDraft.proteinGrams}></Field>
@ -54,10 +58,10 @@
<Field label="Vegetable servings"><input class="input" type="number" min="0" step="0.1" bind:value={estimateDraft.vegetableServings}></Field> <Field label="Vegetable servings"><input class="input" type="number" min="0" step="0.1" bind:value={estimateDraft.vegetableServings}></Field>
<Field className="md:col-span-2" label="Food groups"><input class="input" bind:value={estimateDraft.foodGroups}></Field> <Field className="md:col-span-2" label="Food groups"><input class="input" bind:value={estimateDraft.foodGroups}></Field>
<Field className="md:col-span-2" label="Notes"><input class="input" bind:value={estimateDraft.notes}></Field> <Field className="md:col-span-2" label="Notes"><input class="input" bind:value={estimateDraft.notes}></Field>
</div> </div>
{/if}
<div class="flex flex-col gap-3 border-t border-slate-200 p-4 md:flex-row md:items-center"> <div class="flex flex-col gap-3 border-t border-slate-200 p-4 md:flex-row md:items-center">
<button class="btn-primary" type="submit" disabled={savingMeal}>{savingMeal ? 'Saving...' : editingEntryId ? 'Analyze again' : 'Analyze and save'}</button> <button class="btn-primary" type="submit" disabled={savingMeal}>{savingMeal ? 'Saving...' : editingEntryId ? 'Analyze again' : 'Analyze and save'}</button>
<button class="btn-secondary" type="button" on:click={saveManualMeal} disabled={savingMeal}>Save manually</button>
{#if editingEntryId}<button class="btn-secondary" type="button" on:click={saveMealEdits}>Save edits</button><button class="btn-secondary" type="button" on:click={cancelEdit}>Cancel</button>{/if} {#if editingEntryId}<button class="btn-secondary" type="button" on:click={saveMealEdits}>Save edits</button><button class="btn-secondary" type="button" on:click={cancelEdit}>Cancel</button>{/if}
{#if status}<p id="status" class:text-red-600={statusError} class="text-sm font-semibold text-green-700">{status}</p>{/if} {#if status}<p id="status" class:text-red-600={statusError} class="text-sm font-semibold text-green-700">{status}</p>{/if}
</div> </div>