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}{/if}
- {#if editingEntryId}
-
AI fills these automatically, or you can enter calories and save manually.
+{status}
{/if}