Compare commits

...

4 commits
v1.7 ... main

Author SHA1 Message Date
Daniel
dd70da8c71 Add Capacitor Health Connect sync
All checks were successful
Android CI / build (push) Successful in 2m6s
Web CI / test (push) Successful in 1m14s
Android Release / release-apk (push) Successful in 1m52s
2026-05-25 16:31:51 +02:00
Daniel
38d9f50c31 Fix Calorie AI release publishing
All checks were successful
Android CI / build (push) Successful in 2m19s
Web CI / test (push) Successful in 1m12s
Android Release / release-apk (push) Successful in 1m45s
2026-05-25 04:11:00 +02:00
Daniel
ef6b3a494c Use Node 22 in Calorie AI workflows
Some checks failed
Android CI / build (push) Successful in 2m26s
Web CI / test (push) Successful in 1m14s
Android Release / release-apk (push) Failing after 1m36s
2026-05-25 04:03:47 +02:00
Daniel
176a9fc3bb 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
2026-05-25 03:55:59 +02:00
14 changed files with 443 additions and 71 deletions

View file

@ -21,7 +21,11 @@ jobs:
- name: Install tools - name: Install tools
run: | run: |
apt-get update apt-get update
apt-get install -y --no-install-recommends ca-certificates curl nodejs npm unzip apt-get install -y --no-install-recommends ca-certificates curl unzip
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt-get install -y --no-install-recommends nodejs
node --version
npm --version
- name: Build web app and sync Capacitor - name: Build web app and sync Capacitor
env: env:
@ -53,7 +57,7 @@ jobs:
release_json="$(node -e 'console.log(JSON.stringify({ tag_name: process.env.GITHUB_REF_NAME, name: `Calorie AI ${process.env.GITHUB_REF_NAME}`, body: "Capacitor Android APK for Calorie AI.", draft: false, prerelease: false }))' | curl -fsS -X POST -H "$auth_header" -H 'Content-Type: application/json' --data-binary @- "$FORGEJO_API/repos/${GITHUB_REPOSITORY}/releases")" release_json="$(node -e 'console.log(JSON.stringify({ tag_name: process.env.GITHUB_REF_NAME, name: `Calorie AI ${process.env.GITHUB_REF_NAME}`, body: "Capacitor Android APK for Calorie AI.", draft: false, prerelease: false }))' | curl -fsS -X POST -H "$auth_header" -H 'Content-Type: application/json' --data-binary @- "$FORGEJO_API/repos/${GITHUB_REPOSITORY}/releases")"
fi fi
release_id="$(printf '%s' "$release_json" | node -e 'let data=""; process.stdin.on("data", c => data += c); process.stdin.on("end", () => console.log(JSON.parse(data).id));')" release_id="$(printf '%s' "$release_json" | node -e 'let data=""; process.stdin.on("data", c => data += c); process.stdin.on("end", () => console.log(JSON.parse(data).id));')"
asset_id="$(printf '%s' "$release_json" | node -e 'let data=""; process.stdin.on("data", c => data += c); process.stdin.on("end", () => { const release = JSON.parse(data); const asset = (release.assets || []).find(item => item.name === \`calorie-ai-${process.env.GITHUB_REF_NAME}.apk\`); if (asset) console.log(asset.id); });')" asset_id="$(printf '%s' "$release_json" | node -e 'let data=""; process.stdin.on("data", c => data += c); process.stdin.on("end", () => { const release = JSON.parse(data); const expected = "calorie-ai-" + process.env.GITHUB_REF_NAME + ".apk"; const asset = (release.assets || []).find(item => item.name === expected); if (asset) console.log(asset.id); });')"
if [ -n "$asset_id" ]; then if [ -n "$asset_id" ]; then
curl -fsS -X DELETE -H "$auth_header" "$FORGEJO_API/repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets/${asset_id}" curl -fsS -X DELETE -H "$auth_header" "$FORGEJO_API/repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets/${asset_id}"
fi fi

View file

@ -19,7 +19,11 @@ jobs:
- name: Install Node and browsers - name: Install Node and browsers
run: | run: |
apt-get update apt-get update
apt-get install -y --no-install-recommends ca-certificates curl nodejs npm postgresql postgresql-client apt-get install -y --no-install-recommends ca-certificates curl postgresql postgresql-client
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt-get install -y --no-install-recommends nodejs
node --version
npm --version
service postgresql start service postgresql start
su - postgres -c "psql -tc \"SELECT 1 FROM pg_roles WHERE rolname='calorie_ai'\" | grep -q 1 || psql -c \"CREATE USER calorie_ai WITH PASSWORD 'calorie_ai';\"" su - postgres -c "psql -tc \"SELECT 1 FROM pg_roles WHERE rolname='calorie_ai'\" | grep -q 1 || psql -c \"CREATE USER calorie_ai WITH PASSWORD 'calorie_ai';\""
su - postgres -c "psql -tc \"SELECT 1 FROM pg_database WHERE datname='calorie_ai_test'\" | grep -q 1 || createdb -O calorie_ai calorie_ai_test" su - postgres -c "psql -tc \"SELECT 1 FROM pg_database WHERE datname='calorie_ai_test'\" | grep -q 1 || createdb -O calorie_ai calorie_ai_test"

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 11
versionName '1.3' versionName '1.11'
} }
buildFeatures { buildFeatures {

View file

@ -1,4 +1,5 @@
apply plugin: 'com.android.application' apply plugin: 'com.android.application'
apply plugin: 'org.jetbrains.kotlin.android'
android { android {
namespace = "com.danvics.calorieai" namespace = "com.danvics.calorieai"
@ -7,8 +8,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 11
versionName "1.0" versionName "1.11"
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.
@ -22,6 +23,13 @@ android {
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
} }
} }
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
kotlinOptions {
jvmTarget = '21'
}
} }
repositories { repositories {
@ -35,6 +43,8 @@ dependencies {
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion" implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion" implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation 'androidx.health.connect:connect-client:1.1.0-alpha12'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0'
implementation project(':capacitor-android') implementation project(':capacitor-android')
testImplementation "junit:junit:$junitVersion" testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"

View file

@ -24,6 +24,17 @@
</activity> </activity>
<activity-alias
android:name=".ViewPermissionUsageActivity"
android:exported="true"
android:permission="android.permission.START_VIEW_PERMISSION_USAGE"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE" />
<category android:name="android.intent.category.HEALTH_PERMISSIONS" />
</intent-filter>
</activity-alias>
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider" android:authorities="${applicationId}.fileprovider"
@ -41,4 +52,7 @@
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" /> <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="androidx.health.permission.READ_STEPS" />
<uses-permission android:name="androidx.health.permission.READ_ACTIVE_CALORIES_BURNED" />
<uses-permission android:name="androidx.health.permission.READ_EXERCISE" />
</manifest> </manifest>

View file

@ -0,0 +1,171 @@
package com.danvics.calorieai
import androidx.activity.result.ActivityResultLauncher
import androidx.health.connect.client.HealthConnectClient
import androidx.health.connect.client.permission.HealthPermission
import androidx.health.connect.client.records.ActiveCaloriesBurnedRecord
import androidx.health.connect.client.records.ExerciseSessionRecord
import androidx.health.connect.client.records.StepsRecord
import androidx.health.connect.client.records.metadata.DataOrigin
import androidx.health.connect.client.request.AggregateRequest
import androidx.health.connect.client.request.ReadRecordsRequest
import androidx.health.connect.client.time.TimeRangeFilter
import androidx.health.connect.client.PermissionController
import com.getcapacitor.JSArray
import com.getcapacitor.JSObject
import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
@CapacitorPlugin(name = "HealthConnect")
class HealthConnectPlugin : Plugin() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private val permissions = setOf(
HealthPermission.getReadPermission(StepsRecord::class),
HealthPermission.getReadPermission(ActiveCaloriesBurnedRecord::class),
HealthPermission.getReadPermission(ExerciseSessionRecord::class),
)
private var permissionLauncher: ActivityResultLauncher<Set<String>>? = null
private var pendingSyncCall: PluginCall? = null
override fun load() {
permissionLauncher = activity.registerForActivityResult(
PermissionController.createRequestPermissionResultContract()
) { granted ->
val call = pendingSyncCall ?: return@registerForActivityResult
pendingSyncCall = null
if (granted.containsAll(permissions)) readAndResolve(call) else call.reject("Health Connect permission was not granted.")
}
}
@PluginMethod
fun sync(call: PluginCall) {
when (HealthConnectClient.getSdkStatus(context)) {
HealthConnectClient.SDK_AVAILABLE -> ensurePermissionsThenSync(call)
HealthConnectClient.SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED -> call.reject("Install or update Health Connect, then try again.")
else -> call.reject("Health Connect is not available on this device.")
}
}
private fun ensurePermissionsThenSync(call: PluginCall) {
scope.launch {
runCatching {
val granted = HealthConnectClient.getOrCreate(context).permissionController.getGrantedPermissions()
if (granted.containsAll(permissions)) {
readActivities(call)
} else {
pendingSyncCall = call
permissionLauncher?.launch(permissions) ?: call.reject("Health Connect permissions are not ready.")
}
}.onFailure { error -> call.reject(error.message ?: "Health Connect sync failed.") }
}
}
private fun readAndResolve(call: PluginCall) {
scope.launch {
runCatching { readActivities(call) }
.onFailure { error -> call.reject(error.message ?: "Health Connect sync failed.") }
}
}
private suspend fun readActivities(call: PluginCall) {
val days = (call.getInt("days") ?: 14).coerceIn(1, 90).toLong()
val client = HealthConnectClient.getOrCreate(context)
val zone = ZoneId.systemDefault()
val today = LocalDate.now(zone)
val syncedAt = Instant.now().toString()
val activities = JSArray()
for (offset in 0 until days) {
val date = today.minusDays(offset)
val start = date.atStartOfDay(zone).toInstant()
val end = date.plusDays(1).atStartOfDay(zone).toInstant()
val aggregate = client.aggregate(
AggregateRequest(
metrics = setOf(StepsRecord.COUNT_TOTAL, ActiveCaloriesBurnedRecord.ACTIVE_CALORIES_TOTAL),
timeRangeFilter = TimeRangeFilter.between(start, end),
)
)
val steps = aggregate[StepsRecord.COUNT_TOTAL] ?: 0L
val calories = aggregate[ActiveCaloriesBurnedRecord.ACTIVE_CALORIES_TOTAL]?.inKilocalories ?: 0.0
if (steps > 0L || calories > 0.0) {
activities.put(
activityJson(
id = "health-connect-day-$date",
sourceName = "Health Connect",
type = "Daily activity",
title = "Daily activity",
startAt = start.toString(),
endAt = end.toString(),
calories = calories,
steps = steps,
syncedAt = syncedAt,
)
)
}
}
val rangeStart = today.minusDays(days - 1).atStartOfDay(zone).toInstant()
val rangeEnd = today.plusDays(1).atStartOfDay(zone).toInstant()
client.readRecords(
ReadRecordsRequest(
recordType = ExerciseSessionRecord::class,
timeRangeFilter = TimeRangeFilter.between(rangeStart, rangeEnd),
pageSize = 200,
)
).records.forEach { session ->
activities.put(
activityJson(
id = "health-connect-session-${session.metadata.id}",
sourceName = sourceName(session.metadata.dataOrigin),
type = "Exercise ${session.exerciseType}",
title = session.title ?: "Exercise session",
startAt = session.startTime.toString(),
endAt = session.endTime.toString(),
syncedAt = syncedAt,
)
)
}
call.resolve(JSObject().put("activities", activities))
}
private fun activityJson(
id: String,
sourceName: String,
type: String,
title: String,
startAt: String,
endAt: String,
calories: Double = 0.0,
steps: Long = 0L,
syncedAt: String,
): JSObject = JSObject()
.put("id", id)
.put("source", "health_connect")
.put("sourceName", sourceName)
.put("type", type)
.put("title", title)
.put("startAt", startAt)
.put("endAt", endAt)
.put("calories", calories)
.put("steps", steps)
.put("distanceMeters", 0)
.put("syncedAt", syncedAt)
private fun sourceName(origin: DataOrigin): String = origin.packageName.ifBlank { "Health Connect" }
override fun handleOnDestroy() {
super.handleOnDestroy()
scope.cancel()
}
}

View file

@ -1,5 +1,12 @@
package com.danvics.calorieai; package com.danvics.calorieai;
import android.os.Bundle;
import com.getcapacitor.BridgeActivity; import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {} public class MainActivity extends BridgeActivity {
@Override
public void onCreate(Bundle savedInstanceState) {
registerPlugin(HealthConnectPlugin.class);
super.onCreate(savedInstanceState);
}
}

View file

@ -9,6 +9,7 @@ buildscript {
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:8.13.0' classpath 'com.android.tools.build:gradle:8.13.0'
classpath 'com.google.gms:google-services:4.4.4' classpath 'com.google.gms:google-services:4.4.4'
classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:2.0.21'
// NOTE: Do not place your application dependencies here; they belong // NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files // in the individual module build.gradle files

View file

@ -1,5 +1,5 @@
ext { ext {
minSdkVersion = 24 minSdkVersion = 26
compileSdkVersion = 36 compileSdkVersion = 36
targetSdkVersion = 36 targetSdkVersion = 36
androidxActivityVersion = '1.11.0' androidxActivityVersion = '1.11.0'
@ -13,4 +13,4 @@ ext {
androidxJunitVersion = '1.3.0' androidxJunitVersion = '1.3.0'
androidxEspressoCoreVersion = '3.7.0' androidxEspressoCoreVersion = '3.7.0'
cordovaAndroidVersion = '14.0.1' cordovaAndroidVersion = '14.0.1'
} }

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

@ -1,5 +1,6 @@
<script> <script>
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { registerPlugin } from '@capacitor/core';
import AppShell from './AppShell.svelte'; import AppShell from './AppShell.svelte';
import DashboardPage from './DashboardPage.svelte'; import DashboardPage from './DashboardPage.svelte';
import AnalyticsPage from './AnalyticsPage.svelte'; import AnalyticsPage from './AnalyticsPage.svelte';
@ -10,6 +11,8 @@
import { defaultSettings, fallbackModels, mealTypes, pages } from './appConfig.js'; import { defaultSettings, fallbackModels, mealTypes, pages } from './appConfig.js';
import { calculateCaloriePlan, planSummary } from './calorie.js'; import { calculateCaloriePlan, planSummary } from './calorie.js';
const HealthConnect = registerPlugin('HealthConnect');
let loginMode = location.pathname === '/login' || location.pathname === '/reset' || location.pathname === '/verify'; let loginMode = location.pathname === '/login' || location.pathname === '/reset' || location.pathname === '/verify';
let username = 'admin'; let username = 'admin';
let password = ''; let password = '';
@ -36,6 +39,7 @@
let modelStatus = ''; let modelStatus = '';
let modelError = false; let modelError = false;
let modelSearching = false; let modelSearching = false;
let modelActionId = '';
let settings = { ...defaultSettings }; let settings = { ...defaultSettings };
let currentPassword = ''; let currentPassword = '';
let newPassword = ''; let newPassword = '';
@ -44,6 +48,9 @@
let accountVerified = false; let accountVerified = false;
let accountStatus = ''; let accountStatus = '';
let accountError = false; let accountError = false;
let healthConnectStatus = 'Health Connect not synced.';
let healthConnectError = false;
let healthConnectSyncing = false;
let passwordStatus = ''; let passwordStatus = '';
let passwordError = false; let passwordError = false;
let resetPasswordValue = ''; let resetPasswordValue = '';
@ -99,11 +106,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 +245,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', {
@ -267,6 +285,30 @@
} catch {} } catch {}
} }
async function syncHealthConnect() {
healthConnectStatus = 'Syncing Health Connect...';
healthConnectError = false;
healthConnectSyncing = true;
try {
const nativeResult = await HealthConnect.sync({ days: 14 });
const synced = Array.isArray(nativeResult.activities) ? nativeResult.activities : [];
const response = await fetch('/api/activities/sync', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ source: 'health_connect', activities: synced }),
});
const data = await response.json().catch(() => ({ error: 'Health Connect server sync failed.' }));
if (!response.ok) throw new Error(data.error || 'Health Connect server sync failed.');
activities = data.activities || [];
healthConnectStatus = `Synced ${synced.length} Health Connect record${synced.length === 1 ? '' : 's'}.`;
} catch (error) {
healthConnectStatus = error?.message || 'Health Connect sync failed.';
healthConnectError = true;
} finally {
healthConnectSyncing = false;
}
}
function applyPlanConfig(config) { function applyPlanConfig(config) {
plans = config.plans || []; plans = config.plans || [];
selectedPlanId = config.selectedPlanId || plans[0]?.id || ''; selectedPlanId = config.selectedPlanId || plans[0]?.id || '';
@ -314,6 +356,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.';
@ -350,43 +406,63 @@
} }
async function addModel(model) { async function addModel(model) {
const response = await fetch('/api/models/add', { modelActionId = model.id;
method: 'POST', modelStatus = `Adding ${model.name || model.id}...`;
headers: { 'content-type': 'application/json' },
body: JSON.stringify(model),
});
if (!response.ok) {
modelStatus = (await response.json().catch(() => ({}))).error || 'Failed to add model.';
modelError = true;
return;
}
const data = await response.json();
models = data.models?.length ? data.models : models;
settings.visionModel = models.some(item => item.id === settings.visionModel) ? settings.visionModel : models[0].id;
settings.taskModel = model.id;
await saveSettings();
modelStatus = `Added ${model.name || model.id}.`;
modelError = false; modelError = false;
try {
const response = await fetch('/api/models/add', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(model),
});
if (!response.ok) {
modelStatus = (await response.json().catch(() => ({}))).error || 'Failed to add model.';
modelError = true;
return;
}
const data = await response.json();
models = data.models?.length ? data.models : models;
settings.visionModel = models.some(item => item.id === settings.visionModel) ? settings.visionModel : models[0].id;
settings.taskModel = model.id;
await saveSettings();
modelStatus = `Added ${model.name || model.id}.`;
modelError = false;
} catch (error) {
modelStatus = error?.message || 'Failed to add model.';
modelError = true;
} finally {
modelActionId = '';
}
} }
async function removeModel(modelId) { async function removeModel(modelId) {
const response = await fetch('/api/models/remove', { modelActionId = modelId;
method: 'POST', modelStatus = 'Removing model...';
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id: modelId }),
});
if (!response.ok) {
modelStatus = (await response.json().catch(() => ({}))).error || 'Failed to remove model.';
modelError = true;
return;
}
const data = await response.json();
models = data.models?.length ? data.models : fallbackModels;
if (!models.some(model => model.id === settings.visionModel)) settings.visionModel = data.defaultModel || models[0].id;
if (!models.some(model => model.id === settings.taskModel)) settings.taskModel = data.defaultModel || models[0].id;
await saveSettings();
modelStatus = 'Model removed.';
modelError = false; modelError = false;
try {
const response = await fetch('/api/models/remove', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id: modelId }),
});
if (!response.ok) {
modelStatus = (await response.json().catch(() => ({}))).error || 'Failed to remove model.';
modelError = true;
return;
}
const data = await response.json();
models = data.models?.length ? data.models : fallbackModels;
if (!models.some(model => model.id === settings.visionModel)) settings.visionModel = data.defaultModel || models[0].id;
if (!models.some(model => model.id === settings.taskModel)) settings.taskModel = data.defaultModel || models[0].id;
await saveSettings();
modelStatus = 'Model removed.';
modelError = false;
} catch (error) {
modelStatus = error?.message || 'Failed to remove model.';
modelError = true;
} finally {
modelActionId = '';
}
} }
async function changePassword() { async function changePassword() {
@ -474,15 +550,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 +701,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 +739,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 +766,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 +1040,7 @@ Include: daily calorie target, maintenance calories, protein target, meal loggin
bind:estimateDraft bind:estimateDraft
{chooseImage} {chooseImage}
{saveMeal} {saveMeal}
{saveManualMeal}
{saveMealEdits} {saveMealEdits}
{cancelEdit} {cancelEdit}
/> />
@ -969,10 +1105,14 @@ Include: daily calorie target, maintenance calories, protein target, meal loggin
{modelStatus} {modelStatus}
{modelError} {modelError}
{modelSearching} {modelSearching}
{modelActionId}
bind:accountEmail bind:accountEmail
{accountVerified} {accountVerified}
{accountStatus} {accountStatus}
{accountError} {accountError}
{healthConnectStatus}
{healthConnectError}
{healthConnectSyncing}
{passwordStatus} {passwordStatus}
{passwordError} {passwordError}
{resetPasswordValue} {resetPasswordValue}
@ -986,6 +1126,7 @@ Include: daily calorie target, maintenance calories, protein target, meal loggin
{removeModel} {removeModel}
{saveAccountEmail} {saveAccountEmail}
{sendVerificationEmail} {sendVerificationEmail}
{syncHealthConnect}
{changePassword} {changePassword}
{resetPassword} {resetPassword}
/> />

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>

View file

@ -8,10 +8,14 @@
export let modelStatus; export let modelStatus;
export let modelError; export let modelError;
export let modelSearching; export let modelSearching;
export let modelActionId;
export let accountEmail; export let accountEmail;
export let accountVerified; export let accountVerified;
export let accountStatus; export let accountStatus;
export let accountError; export let accountError;
export let healthConnectStatus;
export let healthConnectError;
export let healthConnectSyncing;
export let passwordStatus; export let passwordStatus;
export let passwordError; export let passwordError;
export let resetPasswordValue; export let resetPasswordValue;
@ -25,6 +29,7 @@
export let removeModel; export let removeModel;
export let saveAccountEmail; export let saveAccountEmail;
export let sendVerificationEmail; export let sendVerificationEmail;
export let syncHealthConnect;
export let changePassword; export let changePassword;
export let resetPassword; export let resetPassword;
</script> </script>
@ -65,7 +70,7 @@
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3"> <div class="rounded-xl border border-slate-200 bg-slate-50 p-3">
<div class="mb-2 text-xs font-bold uppercase tracking-wide text-slate-500">Saved models</div> <div class="mb-2 text-xs font-bold uppercase tracking-wide text-slate-500">Saved models</div>
<div class="max-h-40 overflow-y-auto pr-1"> <div class="max-h-40 overflow-y-auto pr-1">
{#each models as model}<div class="mb-2 flex items-center gap-2 rounded-lg bg-white px-3 py-2 text-sm shadow-sm"><span class="min-w-0 flex-1 truncate"><strong>{model.name}</strong> <span class="text-slate-400">{model.id}</span></span><button class="rounded-md bg-red-50 px-2 py-1 text-xs font-bold text-red-600 hover:bg-red-100" type="button" on:click={() => removeModel(model.id)}>Remove</button></div>{/each} {#each models as model}<div class="mb-2 grid gap-2 rounded-lg bg-white px-3 py-2 text-sm shadow-sm sm:flex sm:items-center"><span class="min-w-0 flex-1 truncate"><strong>{model.name}</strong> <span class="text-slate-400">{model.id}</span></span><button class="rounded-lg bg-red-50 px-3 py-2 text-xs font-bold text-red-600 hover:bg-red-100 disabled:opacity-60" type="button" on:click={() => removeModel(model.id)} disabled={modelActionId === model.id}>{modelActionId === model.id ? 'Removing...' : 'Remove'}</button></div>{/each}
</div> </div>
</div> </div>
</div> </div>
@ -73,7 +78,7 @@
<label class="mb-2 block text-sm font-bold text-slate-700" for="modelSearch">Search provider models</label> <label class="mb-2 block text-sm font-bold text-slate-700" for="modelSearch">Search provider models</label>
<div class="flex flex-col gap-2 sm:flex-row"><input id="modelSearch" class="input" bind:value={modelSearch} placeholder="Search current models, e.g. claude, gemini, gpt" on:keydown={(event) => event.key === 'Enter' && searchModels()}><button class="btn-primary sm:w-36" type="button" on:click={searchModels} disabled={modelSearching}>{modelSearching ? 'Searching...' : 'Search'}</button></div> <div class="flex flex-col gap-2 sm:flex-row"><input id="modelSearch" class="input" bind:value={modelSearch} placeholder="Search current models, e.g. claude, gemini, gpt" on:keydown={(event) => event.key === 'Enter' && searchModels()}><button class="btn-primary sm:w-36" type="button" on:click={searchModels} disabled={modelSearching}>{modelSearching ? 'Searching...' : 'Search'}</button></div>
<div class="mt-3 max-h-72 overflow-y-auto rounded-xl border border-slate-200 bg-white p-2"> <div class="mt-3 max-h-72 overflow-y-auto rounded-xl border border-slate-200 bg-white p-2">
{#each discoveredModels as model}<div class="mb-2 flex items-center gap-2 rounded-lg border border-slate-100 px-3 py-2 text-sm"><button class="rounded-md bg-blue-600 px-2.5 py-1 text-xs font-black text-white hover:bg-blue-700" type="button" on:click={() => addModel(model)}>+</button><span class="min-w-0 flex-1 truncate"><strong>{model.name}</strong> <span class="text-slate-400">{model.id}</span></span></div>{/each} {#each discoveredModels as model}<div class="mb-2 grid gap-2 rounded-lg border border-slate-100 px-3 py-2 text-sm sm:flex sm:items-center"><span class="min-w-0 flex-1 truncate"><strong>{model.name}</strong> <span class="text-slate-400">{model.id}</span></span><button class="rounded-lg bg-blue-600 px-3 py-2 text-xs font-black text-white hover:bg-blue-700 disabled:opacity-60 sm:order-first" type="button" on:click={() => addModel(model)} disabled={modelActionId === model.id}>{modelActionId === model.id ? 'Adding...' : 'Add'}</button></div>{/each}
</div> </div>
</div> </div>
</div> </div>
@ -90,6 +95,10 @@
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm"><strong class="block text-slate-900">2. Health Connect</strong>In Garmin Connect, enable sharing to Android Health Connect. Garmin sends steps, calories, and workouts after each device sync.</div> <div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm"><strong class="block text-slate-900">2. Health Connect</strong>In Garmin Connect, enable sharing to Android Health Connect. Garmin sends steps, calories, and workouts after each device sync.</div>
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm"><strong class="block text-slate-900">3. Calorie AI Android</strong>Open Calorie AI on Android, go to Settings, and tap Sync Health Connect. The web dashboard updates after the server receives the sync.</div> <div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm"><strong class="block text-slate-900">3. Calorie AI Android</strong>Open Calorie AI on Android, go to Settings, and tap Sync Health Connect. The web dashboard updates after the server receives the sync.</div>
</div> </div>
<div class="flex flex-col gap-3 border-t border-slate-100 px-4 py-4 sm:flex-row sm:items-center">
<button class="btn-primary sm:w-52" type="button" on:click={syncHealthConnect} disabled={healthConnectSyncing}>{healthConnectSyncing ? 'Syncing...' : 'Sync Health Connect'}</button>
<p id="healthConnectStatus" class:text-red-600={healthConnectError} class="text-sm font-semibold text-green-700">{healthConnectStatus}</p>
</div>
<div class="border-t border-slate-100 bg-slate-50 px-4 py-3 text-xs leading-5 text-slate-500">Direct Garmin web login is not available because Garmin Health API requires partner approval and API credentials.</div> <div class="border-t border-slate-100 bg-slate-50 px-4 py-3 text-xs leading-5 text-slate-500">Direct Garmin web login is not available because Garmin Health API requires partner approval and API credentials.</div>
</section> </section>
</div> </div>

View file

@ -6,15 +6,17 @@ const png = Buffer.from(
); );
async function mockModels(page) { async function mockModels(page) {
let models = [
{ id: 'claude-haiku-4.5', name: 'Claude Haiku 4.5' },
];
await page.route('**/api/models', async (route) => { await page.route('**/api/models', async (route) => {
await route.fulfill({ await route.fulfill({
status: 200, status: 200,
contentType: 'application/json', contentType: 'application/json',
body: JSON.stringify({ body: JSON.stringify({
defaultModel: 'claude-haiku-4.5', defaultModel: 'claude-haiku-4.5',
models: [ models,
{ id: 'claude-haiku-4.5', name: 'Claude Haiku 4.5' },
],
}), }),
}); });
}); });
@ -35,19 +37,28 @@ async function mockModels(page) {
await page.route('**/api/models/add', async (route) => { await page.route('**/api/models/add', async (route) => {
const body = route.request().postDataJSON(); const body = route.request().postDataJSON();
models = [...models.filter(model => model.id !== body.id), { id: body.id, name: body.name || body.id }];
await route.fulfill({ await route.fulfill({
status: 200, status: 200,
contentType: 'application/json', contentType: 'application/json',
body: JSON.stringify({ body: JSON.stringify({
ok: true, ok: true,
defaultModel: 'claude-haiku-4.5', defaultModel: 'claude-haiku-4.5',
models: [ models,
{ id: 'claude-haiku-4.5', name: 'Claude Haiku 4.5' },
{ id: body.id, name: body.name || body.id },
],
}), }),
}); });
}); });
await page.route('**/api/models/remove', async (route) => {
const body = route.request().postDataJSON();
models = models.filter(model => model.id !== body.id);
if (!models.length) models = [{ id: 'claude-haiku-4.5', name: 'Claude Haiku 4.5' }];
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ ok: true, defaultModel: models[0].id, models }),
});
});
} }
async function mockPlans(page) { async function mockPlans(page) {
@ -254,11 +265,14 @@ test.describe('authenticated app', () => {
await page.locator('#modelSearch').fill('gemini'); await page.locator('#modelSearch').fill('gemini');
await page.getByRole('button', { name: 'Search' }).click(); await page.getByRole('button', { name: 'Search' }).click();
await expect(page.getByText('Gemini 2.5 Flash')).toBeVisible(); await expect(page.getByText('Gemini 2.5 Flash')).toBeVisible();
await page.getByRole('button', { name: '+' }).last().click(); await page.getByRole('button', { name: 'Add' }).last().click();
await expect(page.locator('#modelStatus')).toContainText('Added Gemini 2.5 Flash.');
await page.locator('#visionModel').selectOption('gemini-2.5-flash'); await page.locator('#visionModel').selectOption('gemini-2.5-flash');
await page.locator('#taskModel').selectOption('gemini-2.5-flash'); await page.locator('#taskModel').selectOption('gemini-2.5-flash');
await page.getByRole('button', { name: 'Save selected models' }).click(); await page.getByRole('button', { name: 'Save selected models' }).click();
await expect(page.locator('#modelStatus')).toContainText('Models saved.'); await expect(page.locator('#modelStatus')).toContainText('Models saved.');
await page.getByRole('button', { name: 'Remove' }).last().click();
await expect(page.locator('#modelStatus')).toContainText('Model removed.');
await page.getByRole('button', { name: 'Log meal' }).click(); await page.getByRole('button', { name: 'Log meal' }).click();
await page.locator('#mealDate').fill('2026-05-18'); await page.locator('#mealDate').fill('2026-05-18');