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'
|
||||
minSdk 26
|
||||
targetSdk 35
|
||||
versionCode 4
|
||||
versionName '1.3'
|
||||
versionCode 8
|
||||
versionName '1.8'
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue