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'
minSdk 26
targetSdk 35
versionCode 4
versionName '1.3'
versionCode 8
versionName '1.8'
}
buildFeatures {

View file

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

View file

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

View file

@ -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}
/>

View file

@ -15,6 +15,7 @@
export let estimateDraft;
export let chooseImage;
export let saveMeal;
export let saveManualMeal;
export let saveMealEdits;
export let cancelEdit;
</script>
@ -43,8 +44,11 @@
</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 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 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>
@ -54,10 +58,10 @@
<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="Notes"><input class="input" bind:value={estimateDraft.notes}></Field>
</div>
{/if}
</div>
<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-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 status}<p id="status" class:text-red-600={statusError} class="text-sm font-semibold text-green-700">{status}</p>{/if}
</div>