Make calorie logging reliable
This commit is contained in:
parent
33d50258ce
commit
176a9fc3bb
5 changed files with 105 additions and 23 deletions
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue