Compare commits

...

7 commits
v1.4 ... 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
Daniel
33d50258ce Improve plan UX and split web UI components
Some checks failed
Android CI / build (push) Successful in 1m52s
Web CI / test (push) Successful in 54s
Android Release / release-apk (push) Failing after 21s
2026-05-21 03:40:47 +02:00
Daniel
f73b58a158 Add Health Connect activity sync
Some checks failed
Android CI / build (push) Successful in 1m43s
Web CI / test (push) Successful in 58s
Android Release / release-apk (push) Failing after 22s
2026-05-21 01:31:15 +02:00
Daniel
fcef0d48db Add personalized plans with Postgres storage
Some checks failed
Android CI / build (push) Successful in 1m34s
Web CI / test (push) Successful in 58s
Android Release / release-apk (push) Failing after 22s
2026-05-20 23:43:03 +02:00
39 changed files with 2283 additions and 574 deletions

View file

@ -21,7 +21,11 @@ jobs:
- name: Install tools
run: |
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
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")"
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));')"
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
curl -fsS -X DELETE -H "$auth_header" "$FORGEJO_API/repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets/${asset_id}"
fi

View file

@ -19,7 +19,14 @@ jobs:
- name: Install Node and browsers
run: |
apt-get update
apt-get install -y --no-install-recommends ca-certificates curl nodejs npm
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
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"
cd web
npm install
npx playwright install --with-deps chromium
@ -31,4 +38,5 @@ jobs:
CALORIE_AI_WEB_USER=admin \
CALORIE_AI_WEB_PASSWORD=test-password \
CALORIE_AI_SESSION_SECRET=test-session-secret \
CALORIE_AI_TEST_DATABASE_URL=postgresql://calorie_ai:calorie_ai@127.0.0.1:5432/calorie_ai_test \
npm test

1
.gitignore vendored
View file

@ -8,5 +8,6 @@ local.properties
web/node_modules/
web/test-results/
web/playwright-report/
web/.test-data/
web/data/
web/.env

View file

@ -8,8 +8,8 @@ Native Android and Dockerized web calorie tracker with meal images, AI nutrition
- Analyze meals through OpenAI-compatible chat completions.
- Uses a vision model for food image calorie and portion estimates.
- Uses an advice model to normalize calories, protein, carbs, fat, fruit servings, vegetable servings, food groups, and notes.
- Stores daily meal entries locally on Android or in browser local storage.
- Stores generated web weight-loss plans on the server in `web/data/plans.json`.
- Stores meal entries, settings, plans, and synced activity in PostgreSQL.
- Imports Garmin Connect and gym app activity through Android Health Connect.
- Shows daily totals plus charts for macros, 7-day calories, and fruit/vegetable intake.
- Admin settings control API base URL, API key, image model, and nutrition/advice model. The two model fields can contain the same model name.
@ -48,6 +48,16 @@ Recommended Obtainium setup:
The APK is a debug build, so Android may require allowing installs from Obtainium and accepting the debug signing certificate.
## Garmin And Gym Activity
Calorie AI imports external activity through Android Health Connect:
- Garmin: enable Health Connect sync in Garmin Connect, then open Calorie AI on Android and use `Settings -> Sync Health Connect`.
- Gym machines: use the machine's companion app or gym app to write workouts to Health Connect, then sync from Calorie AI.
- Imported data includes daily steps, active calories, and exercise sessions when Health Connect exposes them.
Direct Garmin Health API sync requires Garmin partner approval and API credentials, so the supported path is Health Connect first.
## Web Frontend
The web frontend lives in `web/`. It serves an authenticated browser UI and a tiny Node proxy at `/api/chat` so OpenAI-compatible endpoints are called server-side instead of directly from the browser.
@ -65,7 +75,9 @@ Then open:
http://127.0.0.1:8095
```
The Docker web server creates credentials on first boot in `web/data/auth.json` if `CALORIE_AI_WEB_PASSWORD` and `CALORIE_AI_SESSION_SECRET` are not supplied. To pin credentials, copy `web/.env.example` to `web/.env` and set strong values before starting Docker Compose.
The Docker web stack includes PostgreSQL. Runtime data lives under `web/data/postgres`, and legacy JSON/SQLite data in `web/data` is migrated on startup.
The Docker web server creates credentials on first boot if `CALORIE_AI_WEB_PASSWORD` and `CALORIE_AI_SESSION_SECRET` are not supplied. To pin credentials, copy `web/.env.example` to `web/.env` and set strong values before starting Docker Compose.
## AI Endpoint
@ -80,6 +92,4 @@ Example base URLs:
- `https://api.openai.com/v1`
- An emulator host-loopback URL ending in `:11434/v1` for a local OpenAI-compatible service
Default admin PIN is `admin`. Change it in the Admin AI Settings panel after first launch.
The web frontend stores AI settings in browser local storage after web login, while generated plan versions are stored server-side. The web login is separate from the Android admin PIN.
The web and Android clients use the same authenticated server APIs for entries, settings, plans, models, and synced activity.

View file

@ -12,8 +12,8 @@ android {
applicationId 'com.danvics.calorieai'
minSdk 26
targetSdk 35
versionCode 4
versionName '1.3'
versionCode 11
versionName '1.11'
}
buildFeatures {
@ -47,6 +47,7 @@ dependencies {
implementation 'androidx.compose.material:material-icons-extended'
implementation 'androidx.compose.ui:ui'
implementation 'androidx.compose.ui:ui-tooling-preview'
implementation 'androidx.health.connect:connect-client:1.1.0-alpha12'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0'
debugImplementation 'androidx.compose.ui:ui-tooling'

View file

@ -1,6 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.health.READ_STEPS" />
<uses-permission android:name="android.permission.health.READ_ACTIVE_CALORIES_BURNED" />
<uses-permission android:name="android.permission.health.READ_EXERCISE" />
<application
android:allowBackup="true"

View file

@ -8,12 +8,15 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.health.connect.client.contracts.HealthPermissionsRequestContract
import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.FileProvider
import com.danvics.calorieai.ai.ImagePayload
import com.danvics.calorieai.ai.NutritionParser
import com.danvics.calorieai.data.*
import com.danvics.calorieai.integrations.HealthConnectAvailability
import com.danvics.calorieai.integrations.HealthConnectSync
import com.danvics.calorieai.ui.CalorieAiApp
import com.danvics.calorieai.ui.CalorieTheme
import com.danvics.calorieai.ui.ConnectScreen
@ -41,6 +44,7 @@ private fun AppRoot(repo: ApiRepository) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val parser = remember { NutritionParser() }
val healthConnect = remember { HealthConnectSync(context) }
val cameraImageFile = remember { File(context.cacheDir, "cam_capture.jpg") }
val cameraImageUri: Uri = remember {
@ -60,8 +64,33 @@ private fun AppRoot(repo: ApiRepository) {
var planStatus by remember { mutableStateOf("") }
var planBusy by remember { mutableStateOf(false) }
var syncing by remember { mutableStateOf(false) }
var healthConnectStatus by remember { mutableStateOf("Health Connect not synced.") }
var mealSaveCount by remember { mutableStateOf(0) }
fun performHealthConnectSync() {
healthConnectStatus = "Syncing Health Connect..."
scope.launch(Dispatchers.IO) {
runCatching {
val activities = healthConnect.readActivities()
repo.syncActivities(activities)
}.onSuccess { activities ->
withContext(Dispatchers.Main) {
appState = appState.copy(activities = activities)
healthConnectStatus = "Synced ${activities.size} Health Connect record${if (activities.size == 1) "" else "s"}."
}
}.onFailure { e ->
withContext(Dispatchers.Main) {
if (e is UnauthorizedException) {
connected = false
appState = AppState()
} else {
healthConnectStatus = "Health Connect sync failed: ${e.message}"
}
}
}
}
}
val imagePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
if (uri != null) {
scope.launch(Dispatchers.IO) {
@ -98,8 +127,33 @@ private fun AppRoot(repo: ApiRepository) {
}
}
val healthPermissionLauncher = rememberLauncherForActivityResult(HealthPermissionsRequestContract()) { granted ->
if (granted.containsAll(healthConnect.permissions)) performHealthConnectSync()
else healthConnectStatus = "Health Connect permission was not granted."
}
fun handleUnauth() { connected = false; appState = AppState() }
fun syncHealthConnect() {
when (healthConnect.availability()) {
HealthConnectAvailability.Unavailable -> {
healthConnectStatus = "Health Connect is not available on this device."
}
HealthConnectAvailability.UpdateRequired -> {
healthConnectStatus = "Install or update Health Connect, then try again."
}
HealthConnectAvailability.Available -> {
scope.launch(Dispatchers.IO) {
val granted = runCatching { healthConnect.hasPermissions() }.getOrDefault(false)
withContext(Dispatchers.Main) {
if (granted) performHealthConnectSync()
else healthPermissionLauncher.launch(healthConnect.permissions)
}
}
}
}
}
fun syncState() {
syncing = true
scope.launch(Dispatchers.IO) {
@ -243,18 +297,22 @@ private fun AppRoot(repo: ApiRepository) {
},
onGeneratePlan = {
val settings = appState.settings
if (settings.heightCm.isBlank() || settings.weightKg.isBlank()) {
planStatus = "Enter height and current weight in Settings first."
val calculated = calculateCalorieTarget(settings)
if (calculated == null) {
planStatus = "Enter sex, age, height, and current weight in Settings first."
return@CalorieAiApp
}
val planSettings = if (settings.calorieTarget.isBlank()) settings.copy(calorieTarget = calculated.goalCalories.toString()) else settings
planBusy = true
planStatus = "Generating plan..."
scope.launch(Dispatchers.IO) {
runCatching {
if (planSettings != settings) repo.saveSettings(planSettings)
val recentMeals = appState.entries.takeLast(10)
.joinToString("; ") { "${it.date} ${it.mealType}: ${it.estimate.mealName}, ${it.estimate.calories} kcal" }
.ifBlank { "none logged yet" }
val content = repo.chat(buildPlanBody(settings.taskModel, settings, recentMeals))
val recentActivity = recentActivitySummary(appState.activities, 10)
val content = repo.chat(buildPlanBody(planSettings.taskModel, planSettings, recentMeals, recentActivity))
val plan = Plan(
id = UUID.randomUUID().toString(),
title = "Plan ${appState.plans.plans.size + 1}",
@ -264,7 +322,7 @@ private fun AppRoot(repo: ApiRepository) {
)
repo.savePlan(plan)
}.onSuccess { plans ->
withContext(Dispatchers.Main) { appState = appState.copy(plans = plans); planStatus = "Plan generated."; planBusy = false }
withContext(Dispatchers.Main) { appState = appState.copy(plans = plans, settings = planSettings); planStatus = "Plan generated."; planBusy = false }
}.onFailure { e ->
withContext(Dispatchers.Main) {
if (e is UnauthorizedException) handleUnauth()
@ -283,7 +341,8 @@ private fun AppRoot(repo: ApiRepository) {
val recentMeals = appState.entries.takeLast(15)
.joinToString("; ") { "${it.date} ${it.mealType}: ${it.estimate.mealName}, ${it.estimate.calories} kcal" }
.ifBlank { "none logged yet" }
val content = repo.chat(buildTuneBody(appState.settings.taskModel, currentPlan.content, note, recentMeals))
val recentActivity = recentActivitySummary(appState.activities, 15)
val content = repo.chat(buildTuneBody(appState.settings.taskModel, currentPlan.content, note, recentMeals, recentActivity))
val version = appState.plans.plans.maxOfOrNull { it.version }?.plus(1) ?: 2
val updated = Plan(
id = UUID.randomUUID().toString(),
@ -319,6 +378,8 @@ private fun AppRoot(repo: ApiRepository) {
.onFailure { e -> withContext(Dispatchers.Main) { if (e is UnauthorizedException) handleUnauth() } }
}
},
healthConnectStatus = healthConnectStatus,
onHealthConnectSync = { syncHealthConnect() },
onDisconnect = {
repo.disconnect()
connected = false
@ -358,6 +419,12 @@ private fun buildTodayHistory(entries: List<MealEntry>, date: String): String {
return "$rows\nTotal so far: ${totals[0]} kcal, P${totals[1]}g C${totals[2]}g F${totals[3]}g"
}
private fun recentActivitySummary(activities: List<ActivityRecord>, limit: Int): String =
activities.take(limit).joinToString("; ") {
val steps = if (it.steps > 0) ", ${it.steps} steps" else ""
"${it.startAt.take(10)} ${it.title}: ${it.calories.toInt()} active kcal$steps (${it.sourceName})"
}.ifBlank { "none synced yet" }
private fun buildNutritionBody(model: String, description: String, measure: String, visionEstimate: String, todayHistory: String): JSONObject =
JSONObject().put("model", model).put("temperature", 0.1)
.put("messages", JSONArray()
@ -372,18 +439,35 @@ private fun buildNutritionBody(model: String, description: String, measure: Stri
"Estimate nutrition for this meal.\nDescription: $description\nPortion: $measure\nImage analysis: $visionEstimate\n\nUser's meals logged today:\n$todayHistory"
)))
private fun buildPlanBody(model: String, settings: ServerSettings, recentMeals: String): JSONObject =
private fun buildPlanBody(model: String, settings: ServerSettings, recentMeals: String, recentActivity: String): JSONObject =
JSONObject().put("model", model).put("temperature", 0.2)
.put("messages", JSONArray()
.put(JSONObject().put("role", "system").put("content", "Create safe, concise weight-loss guidance. Do not diagnose disease. Use plain text section titles. Do not use markdown symbols. Recommend medical review for pregnancy, eating disorders, chronic disease, or aggressive goals."))
.put(JSONObject().put("role", "system").put("content", "Create safe, concise weight-management guidance. Do not diagnose disease. Use plain text section titles. Do not use markdown symbols. Recommend medical review for pregnancy, eating disorders, chronic disease, or aggressive goals. Make it feel like a personalized plan built from onboarding answers, not a generic article."))
.put(JSONObject().put("role", "user").put("content",
"Create a personalized weight-loss plan: height ${settings.heightCm} cm, weight ${settings.weightKg} kg, target ${settings.targetWeightKg.ifBlank { "not set" }} kg, activity ${settings.activityLevel}, pace ${settings.pace}. Include daily calorie range, protein target, habits, weekly review steps, safety notes. Recent meals: $recentMeals"
"Create a personalized plan using these details:\n" +
"Goal: ${settings.goal}\n" +
"Sex: ${settings.sex}\n" +
"Age: ${settings.age}\n" +
"Height: ${settings.heightCm} cm\n" +
"Current weight: ${settings.weightKg} kg\n" +
"Target weight: ${settings.targetWeightKg.ifBlank { "not set" }} kg\n" +
"Activity: ${settings.activityLevel}\n" +
"Pace: ${settings.pace}\n" +
"Calculated targets: ${calorieTargetSummary(settings)}\n" +
"Motivation: ${settings.motivation.ifBlank { "not set" }}\n" +
"Biggest challenge: ${settings.challenge.ifBlank { "not set" }}\n" +
"Tracking style: ${settings.trackingStyle.ifBlank { "not set" }}\n" +
"Calorie-counting experience: ${settings.calorieCountingExperience.ifBlank { "not set" }}\n" +
"Fasting interest: ${settings.fastingInterest.ifBlank { "not set" }}\n" +
"Recent meals: $recentMeals\n" +
"Recent activity: $recentActivity\n\n" +
"Include: daily calorie target, maintenance calories, protein target, meal logging strategy, activity adjustment rules, challenge-specific tactics, weekly review steps, and safety notes."
)))
private fun buildTuneBody(model: String, currentContent: String, note: String, recentMeals: String): JSONObject =
private fun buildTuneBody(model: String, currentContent: String, note: String, recentMeals: String, recentActivity: String): JSONObject =
JSONObject().put("model", model).put("temperature", 0.2)
.put("messages", JSONArray()
.put(JSONObject().put("role", "system").put("content", "Update this weight-loss plan using the notes and recent meals. Use plain text section titles. Do not use markdown symbols."))
.put(JSONObject().put("role", "user").put("content",
"Current plan:\n$currentContent\n\nUpdate notes:\n$note\n\nRecent meals:\n$recentMeals"
"Current plan:\n$currentContent\n\nUpdate notes:\n$note\n\nRecent meals:\n$recentMeals\n\nRecent activity:\n$recentActivity"
)))

View file

@ -43,12 +43,14 @@ class ApiRepository(private val prefs: SharedPreferences) {
fun getAppState(): AppState {
val entries = JSONObject(get("/api/entries"))
val activities = JSONObject(get("/api/activities"))
val settings = parseSettings(JSONObject(get("/api/settings")))
val plans = parsePlans(JSONObject(get("/api/plans")))
val models = runCatching { parseModels(JSONObject(post("/api/models", "{}"))) }.getOrDefault(emptyList())
return AppState(
entries = parseEntries(entries.optJSONArray("entries")),
trash = parseEntries(entries.optJSONArray("trash")),
activities = parseActivities(activities.optJSONArray("activities")),
plans = plans,
settings = settings,
models = models
@ -75,12 +77,20 @@ class ApiRepository(private val prefs: SharedPreferences) {
fun saveSettings(settings: ServerSettings): ServerSettings =
parseSettings(JSONObject(post("/api/settings", JSONObject()
.put("sex", settings.sex)
.put("age", settings.age)
.put("heightCm", settings.heightCm)
.put("weightKg", settings.weightKg)
.put("targetWeightKg", settings.targetWeightKg)
.put("goal", settings.goal)
.put("calorieTarget", settings.calorieTarget)
.put("activityLevel", settings.activityLevel)
.put("pace", settings.pace)
.put("motivation", settings.motivation)
.put("challenge", settings.challenge)
.put("trackingStyle", settings.trackingStyle)
.put("calorieCountingExperience", settings.calorieCountingExperience)
.put("fastingInterest", settings.fastingInterest)
.toString())))
fun getPlans(): PlansState = parsePlans(JSONObject(get("/api/plans")))
@ -101,6 +111,14 @@ class ApiRepository(private val prefs: SharedPreferences) {
fun deletePlan(id: String): PlansState =
parsePlans(JSONObject(post("/api/plans/delete", JSONObject().put("id", id).toString())))
fun syncActivities(activities: List<ActivityRecord>): List<ActivityRecord> {
val body = JSONObject()
.put("source", "health_connect")
.put("activities", JSONArray(activities.map { it.toJson() }))
.toString()
return parseActivities(JSONObject(post("/api/activities/sync", body)).optJSONArray("activities"))
}
fun chat(body: JSONObject): String {
val response = JSONObject(post("/api/chat", JSONObject().put("body", body).toString()))
return response.getJSONArray("choices").getJSONObject(0).getJSONObject("message").getString("content").trim()
@ -139,6 +157,9 @@ class ApiRepository(private val prefs: SharedPreferences) {
private fun parseEntries(arr: JSONArray?): List<MealEntry> =
(0 until (arr?.length() ?: 0)).mapNotNull { arr?.optJSONObject(it)?.toMealEntry() }
private fun parseActivities(arr: JSONArray?): List<ActivityRecord> =
(0 until (arr?.length() ?: 0)).mapNotNull { arr?.optJSONObject(it)?.toActivityRecord() }
fun clearTrash(): Pair<List<MealEntry>, List<MealEntry>> {
val root = JSONObject(post("/api/entries/clear-trash", "{}"))
return parseEntries(root.optJSONArray("entries")) to parseEntries(root.optJSONArray("trash"))
@ -147,12 +168,20 @@ class ApiRepository(private val prefs: SharedPreferences) {
private fun parseSettings(json: JSONObject) = ServerSettings(
visionModel = json.optString("visionModel"),
taskModel = json.optString("taskModel"),
sex = json.optString("sex"),
age = json.optString("age"),
heightCm = json.optString("heightCm"),
weightKg = json.optString("weightKg"),
targetWeightKg = json.optString("targetWeightKg"),
goal = json.optString("goal", "Lose weight").ifBlank { "Lose weight" },
calorieTarget = json.optString("calorieTarget"),
activityLevel = json.optString("activityLevel", "Moderate").ifBlank { "Moderate" },
pace = json.optString("pace", "Steady").ifBlank { "Steady" }
pace = json.optString("pace", "Steady").ifBlank { "Steady" },
motivation = json.optString("motivation"),
challenge = json.optString("challenge"),
trackingStyle = json.optString("trackingStyle"),
calorieCountingExperience = json.optString("calorieCountingExperience"),
fastingInterest = json.optString("fastingInterest")
)
private fun parsePlans(json: JSONObject): PlansState {
@ -213,3 +242,30 @@ fun JSONObject.toMealEntry(): MealEntry = MealEntry(
foodGroups = optString("foodGroups"), notes = optString("notes"), raw = optString("rawAi")
)
)
fun ActivityRecord.toJson(): JSONObject = JSONObject()
.put("id", id)
.put("source", source)
.put("sourceName", sourceName)
.put("type", type)
.put("title", title)
.put("startAt", startAt)
.put("endAt", endAt)
.put("calories", calories)
.put("steps", steps)
.put("distanceMeters", distanceMeters)
.put("syncedAt", syncedAt)
fun JSONObject.toActivityRecord(): ActivityRecord = ActivityRecord(
id = optString("id"),
source = optString("source", "external"),
sourceName = optString("sourceName", optString("source", "External")),
type = optString("type", "Activity"),
title = optString("title", optString("type", "Activity")),
startAt = optString("startAt"),
endAt = optString("endAt"),
calories = optDouble("calories"),
steps = optLong("steps"),
distanceMeters = optDouble("distanceMeters"),
syncedAt = optString("syncedAt")
)

View file

@ -0,0 +1,66 @@
package com.danvics.calorieai.data
import kotlin.math.abs
import kotlin.math.ceil
import kotlin.math.roundToInt
data class CalorieTarget(
val bmr: Int,
val maintenanceCalories: Int,
val goalCalories: Int,
val proteinTarget: Int,
val weeklyKg: Double,
val weeksToTarget: Int
)
fun calculateCalorieTarget(settings: ServerSettings): CalorieTarget? {
val weight = settings.weightKg.toDoubleOrNull()?.takeIf { it > 0 } ?: return null
val height = settings.heightCm.toDoubleOrNull()?.takeIf { it > 0 } ?: return null
val age = settings.age.toDoubleOrNull()?.takeIf { it > 0 } ?: return null
if (settings.sex.isBlank()) return null
val bmr = 10 * weight + 6.25 * height - 5 * age + if (settings.sex == "Male") 5 else -161
val maintenance = roundTo(bmr * activityFactor(settings.activityLevel))
val goalCalories = maxOf(if (settings.sex == "Male") 1500 else 1200, roundTo(maintenance.toDouble() + paceDelta(settings.pace, settings.goal)))
val protein = roundTo(weight * if (settings.goal == "Build muscle") 1.8 else if (settings.goal == "Maintain weight") 1.3 else 1.6, 5)
val weeklyKg = weeklyChangeKg(settings.pace, settings.goal)
val targetWeight = settings.targetWeightKg.toDoubleOrNull() ?: 0.0
val remaining = if (targetWeight > 0) targetWeight - weight else 0.0
val weeks = if (remaining != 0.0 && weeklyKg != 0.0 && remaining.sign() == weeklyKg.sign()) ceil(abs(remaining / weeklyKg)).toInt() else 0
return CalorieTarget(roundTo(bmr), maintenance, goalCalories, protein, weeklyKg, weeks)
}
fun calorieTargetSummary(settings: ServerSettings): String {
val target = calculateCalorieTarget(settings) ?: return "Add sex, age, height, weight, and activity level to calculate a daily target."
val pace = when {
target.weeklyKg > 0 -> "gain about ${"%.2f".format(target.weeklyKg)} kg/week"
target.weeklyKg < 0 -> "lose about ${"%.2f".format(abs(target.weeklyKg))} kg/week"
else -> "maintain weight"
}
val weeks = if (target.weeksToTarget > 0) " Estimated time to target: ${target.weeksToTarget} weeks." else ""
return "${target.goalCalories} kcal/day, ${target.proteinTarget} g protein/day, $pace.$weeks"
}
private fun activityFactor(level: String) = when (level) {
"Low" -> 1.2
"Moderate" -> 1.55
"High" -> 1.725
else -> 1.375
}
private fun paceDelta(pace: String, goal: String) = when (goal) {
"Gain weight", "Build muscle" -> if (pace == "Aggressive") 500 else if (pace == "Steady") 350 else 250
"Maintain weight" -> 0
else -> if (pace == "Aggressive") -750 else if (pace == "Steady") -500 else -250
}
private fun weeklyChangeKg(pace: String, goal: String): Double {
if (goal == "Maintain weight") return 0.0
val amount = if (pace == "Aggressive") 0.75 else if (pace == "Steady") 0.5 else 0.25
return if (goal == "Gain weight" || goal == "Build muscle") amount else -amount
}
private fun roundTo(value: Double, step: Int = 10) = (value / step).roundToInt() * step
private fun Double.sign() = if (this > 0) 1 else if (this < 0) -1 else 0

View file

@ -41,6 +41,20 @@ data class DayTotals(
data class ModelOption(val id: String, val name: String = id)
data class ActivityRecord(
val id: String,
val source: String,
val sourceName: String,
val type: String,
val title: String,
val startAt: String,
val endAt: String,
val calories: Double = 0.0,
val steps: Long = 0,
val distanceMeters: Double = 0.0,
val syncedAt: String = ""
)
data class LocalConfig(val serverUrl: String = "", val sessionCookie: String = "") {
fun isConnected() = serverUrl.isNotBlank() && sessionCookie.isNotBlank()
}
@ -62,17 +76,26 @@ data class PlansState(
data class ServerSettings(
val visionModel: String = "",
val taskModel: String = "",
val sex: String = "",
val age: String = "",
val heightCm: String = "",
val weightKg: String = "",
val targetWeightKg: String = "",
val goal: String = "Lose weight",
val calorieTarget: String = "",
val activityLevel: String = "Moderate",
val pace: String = "Steady"
val pace: String = "Steady",
val motivation: String = "",
val challenge: String = "",
val trackingStyle: String = "",
val calorieCountingExperience: String = "",
val fastingInterest: String = ""
)
data class AppState(
val entries: List<MealEntry> = emptyList(),
val trash: List<MealEntry> = emptyList(),
val activities: List<ActivityRecord> = emptyList(),
val plans: PlansState = PlansState(),
val settings: ServerSettings = ServerSettings(),
val models: List<ModelOption> = emptyList()

View file

@ -0,0 +1,98 @@
package com.danvics.calorieai.integrations
import android.content.Context
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.request.AggregateRequest
import androidx.health.connect.client.request.ReadRecordsRequest
import androidx.health.connect.client.time.TimeRangeFilter
import com.danvics.calorieai.data.ActivityRecord
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
class HealthConnectSync(private val context: Context) {
val permissions = setOf(
HealthPermission.getReadPermission(StepsRecord::class),
HealthPermission.getReadPermission(ActiveCaloriesBurnedRecord::class),
HealthPermission.getReadPermission(ExerciseSessionRecord::class),
)
fun availability(): HealthConnectAvailability = when (HealthConnectClient.getSdkStatus(context)) {
HealthConnectClient.SDK_AVAILABLE -> HealthConnectAvailability.Available
HealthConnectClient.SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED -> HealthConnectAvailability.UpdateRequired
else -> HealthConnectAvailability.Unavailable
}
suspend fun hasPermissions(): Boolean {
if (availability() != HealthConnectAvailability.Available) return false
val granted = HealthConnectClient.getOrCreate(context).permissionController.getGrantedPermissions()
return granted.containsAll(permissions)
}
suspend fun readActivities(days: Long = 14): List<ActivityRecord> {
if (!hasPermissions()) return emptyList()
val client = HealthConnectClient.getOrCreate(context)
val zone = ZoneId.systemDefault()
val today = LocalDate.now(zone)
val syncedAt = Instant.now().toString()
val daily = (0 until days).mapNotNull { offset ->
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) null else ActivityRecord(
id = "health-connect-day-${date}",
source = "health_connect",
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()
val sessions = client.readRecords(
ReadRecordsRequest(
recordType = ExerciseSessionRecord::class,
timeRangeFilter = TimeRangeFilter.between(rangeStart, rangeEnd),
pageSize = 200,
)
).records.map { session ->
val source = session.metadata.dataOrigin.packageName
ActivityRecord(
id = "health-connect-session-${session.metadata.id}",
source = "health_connect",
sourceName = source.ifBlank { "Health Connect" },
type = "Exercise ${session.exerciseType}",
title = session.title ?: "Exercise session",
startAt = session.startTime.toString(),
endAt = session.endTime.toString(),
syncedAt = syncedAt,
)
}
return (daily + sessions).sortedByDescending { it.startAt }
}
}
enum class HealthConnectAvailability {
Available,
UpdateRequired,
Unavailable,
}

View file

@ -50,6 +50,8 @@ fun CalorieAiApp(
onTunePlan: (String, String) -> Unit,
onSelectPlan: (String) -> Unit,
onDeletePlan: (String) -> Unit,
healthConnectStatus: String,
onHealthConnectSync: () -> Unit,
onDisconnect: () -> Unit
) {
CalorieTheme {
@ -95,7 +97,7 @@ fun CalorieAiApp(
) { padding ->
Box(Modifier.padding(padding).fillMaxSize()) {
when (screen) {
Screen.Dashboard -> DashboardScreen(appState.entries, appState.settings, onLog = { screen = Screen.Log })
Screen.Dashboard -> DashboardScreen(appState.entries, appState.activities, appState.settings, onLog = { screen = Screen.Log })
Screen.Log -> LogMealScreen(
editing = editing,
selectedImageName = selectedImageName,
@ -117,7 +119,7 @@ fun CalorieAiApp(
onClearTrash = onClearTrash
)
Screen.Plans -> PlansScreen(appState.settings, appState.plans, planStatus, planBusy, onGeneratePlan, onTunePlan, onSelectPlan, onDeletePlan)
Screen.Settings -> SettingsScreen(appState.settings, serverUrl, onSettingsChange, onDisconnect)
Screen.Settings -> SettingsScreen(appState.settings, serverUrl, healthConnectStatus, onSettingsChange, onHealthConnectSync, onDisconnect)
}
}
}

View file

@ -6,18 +6,23 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.danvics.calorieai.data.ActivityRecord
import com.danvics.calorieai.data.MealEntry
import com.danvics.calorieai.data.MealStats
import com.danvics.calorieai.data.ServerSettings
import com.danvics.calorieai.data.calculateCalorieTarget
import java.time.LocalDate
@Composable
fun DashboardScreen(entries: List<MealEntry>, settings: ServerSettings, onLog: () -> Unit) = Page {
fun DashboardScreen(entries: List<MealEntry>, activities: List<ActivityRecord>, settings: ServerSettings, onLog: () -> Unit) = Page {
val today = MealStats.totalsForDate(entries, LocalDate.now().toString())
val todayActivities = activities.filter { it.startAt.take(10) == LocalDate.now().toString() }
val activityCalories = todayActivities.sumOf { it.calories }.toInt()
val activitySteps = todayActivities.sumOf { it.steps }
val week = MealStats.lastSevenDays(entries)
val avgCalories = if (week.isEmpty()) 0 else week.sumOf { it.calories } / week.size
val macroTotal = today.protein + today.carbs + today.fat
val calorieTarget = settings.calorieTarget.toIntOrNull() ?: 0
val calorieTarget = settings.calorieTarget.toIntOrNull() ?: calculateCalorieTarget(settings)?.goalCalories ?: 0
Card(
modifier = Modifier.fillMaxWidth(),
@ -36,6 +41,11 @@ fun DashboardScreen(entries: List<MealEntry>, settings: ServerSettings, onLog: (
StatTile("7-day avg", "$avgCalories kcal", Modifier.weight(1f))
}
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
StatTile("Activity", "$activityCalories kcal", Modifier.weight(1f))
StatTile("Steps", activitySteps.toString(), Modifier.weight(1f))
}
if (calorieTarget > 0) {
SectionCard("Daily calorie goal") {
CalorieGoalBar(today.calories, calorieTarget)
@ -76,4 +86,17 @@ fun DashboardScreen(entries: List<MealEntry>, settings: ServerSettings, onLog: (
}
}
}
SectionCard("Synced activity") {
if (todayActivities.isEmpty()) {
Text("No activity synced yet. Connect Garmin or gym apps through Health Connect.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
} else {
todayActivities.take(5).forEach { activity ->
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(activity.title, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
Text("${activity.calories.toInt()} kcal", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
}

View file

@ -9,6 +9,8 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.danvics.calorieai.data.PlansState
import com.danvics.calorieai.data.ServerSettings
import com.danvics.calorieai.data.calculateCalorieTarget
import com.danvics.calorieai.data.calorieTargetSummary
@Composable
fun PlansScreen(
@ -22,6 +24,7 @@ fun PlansScreen(
onDelete: (String) -> Unit
) = Page {
val selectedPlan = plansState.plans.find { it.id == plansState.selectedPlanId } ?: plansState.plans.firstOrNull()
val calorieTarget = calculateCalorieTarget(settings)
var tuneNote by remember { mutableStateOf("") }
var showDeleteConfirm by remember { mutableStateOf(false) }
@ -44,18 +47,21 @@ fun PlansScreen(
SectionCard("Generate plan") {
Text(
"Your plan is built from your profile and recent meals, then updated as you log and progress.",
"Your plan is built from your body stats, goal, habits, challenges, and recent meals.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (calorieTarget != null) {
Text(calorieTargetSummary(settings), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary)
}
Button(
enabled = !planBusy && settings.heightCm.isNotBlank() && settings.weightKg.isNotBlank(),
enabled = !planBusy && calorieTarget != null,
onClick = onGenerate,
modifier = Modifier.fillMaxWidth()
) { Text(if (planBusy) "Generating..." else "Generate new plan") }
if (settings.heightCm.isBlank() || settings.weightKg.isBlank()) {
if (calorieTarget == null) {
Text(
"Fill in your height and weight in Settings first.",
"Fill in sex, age, height, and weight in Settings first.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)

View file

@ -5,17 +5,28 @@ import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.danvics.calorieai.data.calculateCalorieTarget
import com.danvics.calorieai.data.calorieTargetSummary
import com.danvics.calorieai.data.ServerSettings
import kotlinx.coroutines.delay
private val SEX_OPTIONS = listOf("Female", "Male")
private val GOAL_OPTIONS = listOf("Lose weight", "Maintain weight", "Gain weight", "Build muscle")
private val ACTIVITY_LEVELS = listOf("Low", "Moderate", "High")
private val PACE_OPTIONS = listOf("Gentle", "Steady", "Aggressive")
private val MOTIVATIONS = listOf("Improve my overall health", "Feel more confident", "Increase my fitness level", "Prepare for an event", "Improve my relationship with food")
private val CHALLENGES = listOf("Resisting cravings", "Staying motivated", "Reducing portion sizes", "Knowing what to eat", "Being too busy")
private val TRACKING_STYLES = listOf("Photo log meals", "Log before eating", "Meal prep and plan ahead", "Track calories closely", "Build a streak")
private val CALORIE_EXPERIENCE = listOf("New to calorie counting", "Tried before", "Experienced")
private val FASTING_INTEREST = listOf("Interested", "Tried it before", "Not interested")
@Composable
fun SettingsScreen(
settings: ServerSettings,
serverUrl: String,
healthConnectStatus: String,
onSettingsChange: (ServerSettings) -> Unit,
onHealthConnectSync: () -> Unit,
onDisconnect: () -> Unit
) = Page {
var draft by remember(settings) { mutableStateOf(settings) }
@ -29,6 +40,10 @@ fun SettingsScreen(
}
SectionCard("Profile") {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
DropdownField("Sex", SEX_OPTIONS, draft.sex, { draft = draft.copy(sex = it) }, modifier = Modifier.weight(1f))
Field("Age", draft.age, { draft = draft.copy(age = it) }, modifier = Modifier.weight(1f), placeholder = "36")
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Field("Height (cm)", draft.heightCm, { draft = draft.copy(heightCm = it) }, modifier = Modifier.weight(1f), placeholder = "175")
Field("Weight (kg)", draft.weightKg, { draft = draft.copy(weightKg = it) }, modifier = Modifier.weight(1f), placeholder = "80")
@ -37,12 +52,38 @@ fun SettingsScreen(
Field("Target weight (kg)", draft.targetWeightKg, { draft = draft.copy(targetWeightKg = it) }, modifier = Modifier.weight(1f), placeholder = "70")
Field("Daily kcal goal", draft.calorieTarget, { draft = draft.copy(calorieTarget = it) }, modifier = Modifier.weight(1f), placeholder = "2000")
}
DropdownField("Main goal", GOAL_OPTIONS, draft.goal, { draft = draft.copy(goal = it) })
DropdownField("Activity level", ACTIVITY_LEVELS, draft.activityLevel, { draft = draft.copy(activityLevel = it) })
DropdownField("Pace", PACE_OPTIONS, draft.pace, { draft = draft.copy(pace = it) })
val calculated = calculateCalorieTarget(draft)
if (calculated != null) {
AssistChip(onClick = { draft = draft.copy(calorieTarget = calculated.goalCalories.toString()) }, label = { Text("Use ${calculated.goalCalories} kcal/day") })
Text(calorieTargetSummary(draft), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
} else {
Text("Add sex, age, height, and weight to calculate calories.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Button(onClick = { onSettingsChange(draft); status = "Profile saved." }, modifier = Modifier.fillMaxWidth()) { Text("Save profile") }
if (status.isNotBlank()) Text(status, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary)
}
SectionCard("Personalization") {
DropdownField("Why this matters", MOTIVATIONS, draft.motivation, { draft = draft.copy(motivation = it) })
DropdownField("Biggest challenge", CHALLENGES, draft.challenge, { draft = draft.copy(challenge = it) })
DropdownField("Tracking style", TRACKING_STYLES, draft.trackingStyle, { draft = draft.copy(trackingStyle = it) })
DropdownField("Calorie-counting experience", CALORIE_EXPERIENCE, draft.calorieCountingExperience, { draft = draft.copy(calorieCountingExperience = it) })
DropdownField("Fasting interest", FASTING_INTEREST, draft.fastingInterest, { draft = draft.copy(fastingInterest = it) })
}
SectionCard("Garmin and gym machines") {
Text(
"Sync Garmin Connect, supported gym equipment apps, and other wearables through Android Health Connect.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Button(onClick = onHealthConnectSync, modifier = Modifier.fillMaxWidth()) { Text("Sync Health Connect") }
Text(healthConnectStatus, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
SectionCard("Server") {
Text("Connected to $serverUrl", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text("AI models and API keys are configured on the server.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)

View file

@ -4,6 +4,7 @@ WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY index.html vite.config.js server.js ./
COPY server ./server
COPY src ./src
COPY public ./public
RUN npm run build && npm prune --omit=dev

View file

@ -1,4 +1,5 @@
apply plugin: 'com.android.application'
apply plugin: 'org.jetbrains.kotlin.android'
android {
namespace = "com.danvics.calorieai"
@ -7,8 +8,8 @@ android {
applicationId "com.danvics.calorieai"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0"
versionCode 11
versionName "1.11"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// 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'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
kotlinOptions {
jvmTarget = '21'
}
}
repositories {
@ -35,6 +43,8 @@ dependencies {
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
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')
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"

View file

@ -24,6 +24,17 @@
</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
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
@ -41,4 +52,7 @@
<uses-permission android:name="android.permission.CAMERA" />
<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="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>

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;
import android.os.Bundle;
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 {
classpath 'com.android.tools.build:gradle:8.13.0'
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
// in the individual module build.gradle files

View file

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

View file

@ -1,21 +1,36 @@
services:
postgres:
image: postgres:16-alpine
container_name: calorie-ai-postgres
restart: unless-stopped
environment:
POSTGRES_DB: calorie_ai
POSTGRES_USER: calorie_ai
POSTGRES_HOST_AUTH_METHOD: trust
ports:
- "127.0.0.1:55432:5432"
volumes:
- ./data/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U calorie_ai -d calorie_ai"]
interval: 5s
timeout: 5s
retries: 20
calorie-ai-web:
build: .
container_name: calorie-ai-web
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
env_file:
- /home/danvics/docker/quiz/backend/.env
environment:
LITELLM_API_BASE: http://litellm:4000/v1
CALORIE_AI_DATABASE_URL: postgresql://calorie_ai@postgres:5432/calorie_ai
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

141
web/package-lock.json generated
View file

@ -10,7 +10,8 @@
"dependencies": {
"argon2": "^0.44.0",
"express": "^5.2.1",
"nodemailer": "^7.0.11"
"nodemailer": "^7.0.11",
"pg": "^8.21.0"
},
"devDependencies": {
"@capacitor/android": "^8.3.4",
@ -2579,6 +2580,95 @@
"dev": true,
"license": "MIT"
},
"node_modules/pg": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz",
"integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.13.0",
"pg-pool": "^3.14.0",
"pg-protocol": "^1.14.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.4.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.13.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz",
"integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz",
"integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -2675,6 +2765,45 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/prompts": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
@ -3073,7 +3202,6 @@
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">= 10.x"
@ -3528,6 +3656,15 @@
"node": ">=8.0"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
},
"node_modules/yallist": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",

View file

@ -23,6 +23,7 @@
"dependencies": {
"argon2": "^0.44.0",
"express": "^5.2.1",
"nodemailer": "^7.0.11"
"nodemailer": "^7.0.11",
"pg": "^8.21.0"
}
}

View file

@ -12,6 +12,8 @@ module.exports = defineConfig({
CALORIE_AI_WEB_USER: 'admin',
CALORIE_AI_WEB_PASSWORD: 'test-password',
CALORIE_AI_SESSION_SECRET: 'test-session-secret',
CALORIE_AI_DATABASE_URL: process.env.CALORIE_AI_TEST_DATABASE_URL || 'postgresql://calorie_ai@127.0.0.1:55432/calorie_ai',
CALORIE_AI_DATA_DIR: '.test-data',
},
},
use: {

View file

@ -4,6 +4,7 @@ const fs = require('fs');
const path = require('path');
const argon2 = require('argon2');
const nodemailer = require('nodemailer');
const { createStore, defaultSettings } = require('./server/storage');
const port = Number(process.env.PORT || 8080);
const publicDir = fs.existsSync(path.join(__dirname, 'dist')) ? path.join(__dirname, 'dist') : path.join(__dirname, 'public');
@ -18,6 +19,7 @@ const sessionCookieName = 'calorie_ai_session';
const sessionSeconds = 60 * 60 * 24 * 7;
let auth;
let modelConfig;
let store;
const aiConfig = {
baseUrl: (process.env.CALORIE_AI_API_BASE_URL || process.env.LITELLM_API_BASE || 'https://llm.danvics.com/v1').replace(/\/+$/, ''),
apiKey: process.env.CALORIE_AI_API_KEY || process.env.LITELLM_API_KEY || process.env.OPENAI_API_KEY || '',
@ -50,23 +52,12 @@ function readBody(req) {
});
}
function readJsonFile(file, fallback) {
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return fallback; }
}
function writePrivateJson(file, value) {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify(value, null, 2));
fs.chmodSync(file, 0o600);
}
async function hashPassword(password) {
return argon2.hash(password, { type: argon2.argon2id });
}
async function loadAuthConfig() {
let stored = {};
stored = readJsonFile(authFile, {});
const stored = await store.getAuthConfig();
const user = process.env.CALORIE_AI_WEB_USER || stored.user || 'admin';
const email = process.env.CALORIE_AI_ADMIN_EMAIL || stored.email || process.env.MAIL_FROM || process.env.SMTP_FROM || '';
@ -86,7 +77,7 @@ async function loadAuthConfig() {
const sessionSecret = process.env.CALORIE_AI_SESSION_SECRET || stored.sessionSecret || crypto.randomBytes(32).toString('hex');
if (!stored.passwordHash || !stored.sessionSecret || stored.password) {
writePrivateJson(authFile, {
await store.saveAuthConfig({
user,
email,
emailVerified: stored.emailVerified === true,
@ -96,15 +87,15 @@ async function loadAuthConfig() {
updatedAt: new Date().toISOString(),
...(plainPassword && !stored.passwordHash ? { initialPassword: plainPassword } : {}),
});
console.log(`Calorie AI auth is stored at ${authFile}`);
console.log(`Calorie AI auth is stored in ${store.databaseLabel}`);
}
return { ...stored, user, email, emailVerified: stored.emailVerified === true, passwordHash, sessionSecret };
}
function saveAuthConfig(extra = {}) {
async function saveAuthConfig(extra = {}) {
auth = { ...auth, ...extra, updatedAt: new Date().toISOString() };
writePrivateJson(authFile, {
await store.saveAuthConfig({
user: auth.user,
email: auth.email || '',
emailVerified: auth.emailVerified === true,
@ -210,41 +201,29 @@ async function sendMail(to, subject, text, html = '') {
await transporter.sendMail({ from: config.from, to, subject, text, html: html || undefined });
}
function loadModelConfig() {
const stored = readJsonFile(modelsFile, {});
async function loadModelConfig() {
const stored = await store.getModelConfig();
const defaultModel = stored.defaultModel || aiConfig.defaultModel;
const models = Array.isArray(stored.models) && stored.models.length
? stored.models
: [{ id: defaultModel, name: defaultModel, tag: 'DEFAULT' }];
const config = { defaultModel, models: dedupeModels(models) };
if (!fs.existsSync(modelsFile)) saveModelConfig(config);
if (!stored.models?.length) await saveModelConfig(config);
return config;
}
function saveModelConfig(config = modelConfig) {
writePrivateJson(modelsFile, { ...config, updatedAt: new Date().toISOString() });
}
function loadPlanConfig() {
const stored = readJsonFile(plansFile, {});
return {
plans: Array.isArray(stored.plans) ? stored.plans : [],
selectedPlanId: stored.selectedPlanId || '',
};
}
function savePlanConfig(config) {
writePrivateJson(plansFile, { ...config, updatedAt: new Date().toISOString() });
async function saveModelConfig(config = modelConfig) {
await store.saveModelConfig({ ...config, updatedAt: new Date().toISOString() });
}
async function getPlans(req, res) {
send(res, 200, JSON.stringify(loadPlanConfig()));
send(res, 200, JSON.stringify(await store.getPlans()));
}
async function createPlan(req, res) {
const payload = JSON.parse(await readBody(req) || '{}');
if (!payload.content) return send(res, 400, JSON.stringify({ error: 'Plan content required' }));
const config = loadPlanConfig();
const config = await store.getPlans();
const plan = {
id: payload.id || crypto.randomUUID(),
title: String(payload.title || `Weight-loss plan ${config.plans.length + 1}`),
@ -253,91 +232,47 @@ async function createPlan(req, res) {
previousPlanId: payload.previousPlanId || undefined,
createdAt: payload.createdAt || new Date().toISOString(),
};
config.plans = [plan, ...config.plans];
config.selectedPlanId = plan.id;
savePlanConfig(config);
send(res, 200, JSON.stringify(config));
send(res, 200, JSON.stringify(await store.savePlan(plan)));
}
async function selectPlanRoute(req, res) {
const payload = JSON.parse(await readBody(req) || '{}');
const config = loadPlanConfig();
if (payload.id && !config.plans.some(plan => plan.id === payload.id)) return send(res, 404, JSON.stringify({ error: 'Plan not found' }));
config.selectedPlanId = payload.id || '';
savePlanConfig(config);
send(res, 200, JSON.stringify(config));
if (payload.id && !await store.planExists(payload.id)) return send(res, 404, JSON.stringify({ error: 'Plan not found' }));
send(res, 200, JSON.stringify(await store.selectPlan(payload.id || '')));
}
async function deletePlanRoute(req, res) {
const payload = JSON.parse(await readBody(req) || '{}');
const config = loadPlanConfig();
config.plans = config.plans.filter(plan => plan.id !== payload.id);
if (config.selectedPlanId === payload.id) config.selectedPlanId = config.plans[0]?.id || '';
savePlanConfig(config);
send(res, 200, JSON.stringify(config));
}
function loadSettings() {
return readJsonFile(settingsFile, {
visionModel: '',
taskModel: '',
heightCm: '',
weightKg: '',
targetWeightKg: '',
calorieTarget: '',
activityLevel: 'Moderate',
pace: 'Steady',
});
}
function saveSettingsData(settings) {
writePrivateJson(settingsFile, { ...settings, updatedAt: new Date().toISOString() });
send(res, 200, JSON.stringify(await store.deletePlan(payload.id)));
}
async function getSettings(req, res) {
send(res, 200, JSON.stringify(loadSettings()));
send(res, 200, JSON.stringify(await store.getSettings()));
}
async function updateSettings(req, res) {
try {
const payload = JSON.parse(await readBody(req) || '{}');
const current = loadSettings();
const allowed = ['visionModel', 'taskModel', 'heightCm', 'weightKg', 'targetWeightKg', 'calorieTarget', 'activityLevel', 'pace'];
const current = await store.getSettings();
const allowed = Object.keys(defaultSettings);
const updated = { ...current };
for (const key of allowed) if (payload[key] !== undefined) updated[key] = payload[key];
saveSettingsData(updated);
await store.saveSettings(updated);
send(res, 200, JSON.stringify(updated));
} catch (error) {
send(res, 400, JSON.stringify({ error: error.message }));
}
}
function loadEntriesData() {
const stored = readJsonFile(entriesFile, {});
return {
entries: Array.isArray(stored.entries) ? stored.entries : [],
trash: Array.isArray(stored.trash) ? stored.trash : [],
};
}
function saveEntriesData(data) {
writePrivateJson(entriesFile, { ...data, updatedAt: new Date().toISOString() });
}
async function getEntriesRoute(req, res) {
send(res, 200, JSON.stringify(loadEntriesData()));
send(res, 200, JSON.stringify(await store.getEntries()));
}
async function upsertEntryRoute(req, res) {
try {
const payload = JSON.parse(await readBody(req) || '{}');
if (!payload.id) return send(res, 400, JSON.stringify({ error: 'Entry ID required' }));
const data = loadEntriesData();
const idx = data.entries.findIndex(e => e.id === payload.id);
if (idx >= 0) data.entries[idx] = { ...data.entries[idx], ...payload };
else data.entries = [...data.entries, payload];
saveEntriesData(data);
send(res, 200, JSON.stringify(data));
send(res, 200, JSON.stringify(await store.upsertMealEntry(payload)));
} catch (error) {
send(res, 400, JSON.stringify({ error: error.message }));
}
@ -347,12 +282,7 @@ async function deleteEntriesRoute(req, res) {
try {
const payload = JSON.parse(await readBody(req) || '{}');
const ids = new Set(Array.isArray(payload.ids) ? payload.ids : [payload.id].filter(Boolean));
const data = loadEntriesData();
const moving = data.entries.filter(e => ids.has(e.id)).map(e => ({ ...e, trashedAt: new Date().toISOString() }));
data.entries = data.entries.filter(e => !ids.has(e.id));
data.trash = [...moving, ...data.trash];
saveEntriesData(data);
send(res, 200, JSON.stringify(data));
send(res, 200, JSON.stringify(await store.moveEntriesToTrash([...ids])));
} catch (error) {
send(res, 400, JSON.stringify({ error: error.message }));
}
@ -361,13 +291,8 @@ async function deleteEntriesRoute(req, res) {
async function restoreEntryRoute(req, res) {
try {
const payload = JSON.parse(await readBody(req) || '{}');
const data = loadEntriesData();
const found = data.trash.find(e => e.id === payload.id);
if (!found) return send(res, 404, JSON.stringify({ error: 'Entry not found in trash' }));
const { trashedAt, ...restored } = found;
data.entries = [restored, ...data.entries];
data.trash = data.trash.filter(e => e.id !== payload.id);
saveEntriesData(data);
const data = await store.restoreEntry(payload.id);
if (!data) return send(res, 404, JSON.stringify({ error: 'Entry not found in trash' }));
send(res, 200, JSON.stringify(data));
} catch (error) {
send(res, 400, JSON.stringify({ error: error.message }));
@ -376,10 +301,21 @@ async function restoreEntryRoute(req, res) {
async function clearTrashRoute(req, res) {
try {
const data = loadEntriesData();
data.trash = [];
saveEntriesData(data);
send(res, 200, JSON.stringify(data));
send(res, 200, JSON.stringify(await store.clearTrash()));
} catch (error) {
send(res, 400, JSON.stringify({ error: error.message }));
}
}
async function getActivitiesRoute(req, res) {
send(res, 200, JSON.stringify(await store.getActivities()));
}
async function syncActivitiesRoute(req, res) {
try {
const payload = JSON.parse(await readBody(req) || '{}');
const activities = Array.isArray(payload.activities) ? payload.activities : [];
send(res, 200, JSON.stringify(await store.upsertActivities(activities, payload.source || '')));
} catch (error) {
send(res, 400, JSON.stringify({ error: error.message }));
}
@ -400,7 +336,7 @@ function send(res, status, body, contentType = 'application/json; charset=utf-8'
'x-content-type-options': 'nosniff',
'referrer-policy': 'same-origin',
'x-frame-options': 'DENY',
'content-security-policy': "default-src 'self'; img-src 'self' data: blob:; script-src 'self'; style-src 'self'; connect-src 'self'; base-uri 'self'; frame-ancestors 'none'",
'content-security-policy': "default-src 'self'; img-src 'self' data: blob:; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; base-uri 'self'; frame-ancestors 'none'",
...headers,
});
res.end(headOnly ? '' : body);
@ -561,7 +497,7 @@ async function addModel(req, res) {
if (!payload.id) return send(res, 400, JSON.stringify({ error: 'Model ID required' }));
modelConfig.models = dedupeModels([...modelConfig.models, { id: payload.id, name: payload.name || payload.id, tag: 'ADDED' }]);
if (!modelConfig.defaultModel) modelConfig.defaultModel = payload.id;
saveModelConfig();
await saveModelConfig();
send(res, 200, JSON.stringify({ ok: true, models: modelConfig.models, defaultModel: modelConfig.defaultModel }));
} catch (error) {
send(res, 400, JSON.stringify({ error: error.message }));
@ -574,7 +510,7 @@ async function removeModel(req, res) {
modelConfig.models = modelConfig.models.filter(model => model.id !== payload.id);
if (!modelConfig.models.length) modelConfig.models = [{ id: aiConfig.defaultModel, name: aiConfig.defaultModel, tag: 'DEFAULT' }];
if (!modelConfig.models.some(model => model.id === modelConfig.defaultModel)) modelConfig.defaultModel = modelConfig.models[0].id;
saveModelConfig();
await saveModelConfig();
send(res, 200, JSON.stringify({ ok: true, models: modelConfig.models, defaultModel: modelConfig.defaultModel }));
} catch (error) {
send(res, 400, JSON.stringify({ error: error.message }));
@ -604,7 +540,7 @@ async function changePassword(req, res) {
if (!await argon2.verify(auth.passwordHash, payload.currentPassword || '')) return send(res, 401, JSON.stringify({ error: 'Current password is incorrect' }));
if (!payload.newPassword || payload.newPassword.length < 8) return send(res, 400, JSON.stringify({ error: 'Use at least 8 characters for the new password' }));
auth.passwordHash = await hashPassword(payload.newPassword);
saveAuthConfig({ passwordHash: auth.passwordHash, resetTokenHash: undefined, resetExpiresAt: undefined });
await saveAuthConfig({ passwordHash: auth.passwordHash, resetTokenHash: undefined, resetExpiresAt: undefined });
send(res, 200, JSON.stringify({ ok: true }));
} catch (error) {
send(res, 400, JSON.stringify({ error: error.message }));
@ -615,7 +551,7 @@ async function resetPassword(req, res) {
try {
if (!auth.email) return send(res, 400, JSON.stringify({ error: 'Set an account email first' }));
const token = crypto.randomBytes(32).toString('base64url');
saveAuthConfig({ resetTokenHash: tokenHash(token), resetExpiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString() });
await saveAuthConfig({ resetTokenHash: tokenHash(token), resetExpiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString() });
const link = `${appOrigin(req)}/reset?token=${encodeURIComponent(token)}`;
const text = `Use this link to reset your Calorie AI password. It expires in 1 hour.\n\n${link}`;
const html = actionEmailHtml('Password reset request', 'Someone requested a password reset for your Calorie AI account. If that was you, click the button below to choose a new password. This link expires in 1 hour. If you did not request this, no action is needed.', link, 'Reset My Password');
@ -635,7 +571,7 @@ async function requestPasswordReset(req, res) {
}
if (!auth.email) return send(res, 200, JSON.stringify({ ok: true, message: 'If the account exists, a reset email was sent.' }));
const token = crypto.randomBytes(32).toString('base64url');
saveAuthConfig({ resetTokenHash: tokenHash(token), resetExpiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString() });
await saveAuthConfig({ resetTokenHash: tokenHash(token), resetExpiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString() });
const link = `${appOrigin(req)}/reset?token=${encodeURIComponent(token)}`;
const text = `Use this link to reset your Calorie AI password. It expires in 1 hour.\n\n${link}`;
const html = actionEmailHtml('Password reset request', 'Someone requested a password reset for your Calorie AI account. If that was you, click the button below to choose a new password. This link expires in 1 hour. If you did not request this, no action is needed.', link, 'Reset My Password');
@ -653,7 +589,7 @@ async function confirmPasswordReset(req, res) {
const valid = auth.resetTokenHash && auth.resetExpiresAt && new Date(auth.resetExpiresAt).getTime() > Date.now() && safeEqual(auth.resetTokenHash, tokenHash(payload.token || ''));
if (!valid) return send(res, 400, JSON.stringify({ error: 'Reset link is invalid or expired' }));
const passwordHash = await hashPassword(payload.newPassword);
saveAuthConfig({ passwordHash, resetTokenHash: undefined, resetExpiresAt: undefined });
await saveAuthConfig({ passwordHash, resetTokenHash: undefined, resetExpiresAt: undefined });
send(res, 200, JSON.stringify({ ok: true }));
} catch (error) {
send(res, 400, JSON.stringify({ error: error.message }));
@ -665,7 +601,7 @@ async function updateAccount(req, res) {
const payload = JSON.parse(await readBody(req) || '{}');
const email = String(payload.email || '').trim();
if (!/^\S+@\S+\.\S+$/.test(email)) return send(res, 400, JSON.stringify({ error: 'Enter a valid email address' }));
saveAuthConfig({ email, emailVerified: email === auth.email ? auth.emailVerified : false });
await saveAuthConfig({ email, emailVerified: email === auth.email ? auth.emailVerified : false });
send(res, 200, JSON.stringify({ ok: true, email: auth.email, emailVerified: auth.emailVerified }));
} catch (error) {
send(res, 400, JSON.stringify({ error: error.message }));
@ -676,7 +612,7 @@ async function sendVerification(req, res) {
try {
if (!auth.email) return send(res, 400, JSON.stringify({ error: 'Set an account email first' }));
const token = crypto.randomBytes(32).toString('base64url');
saveAuthConfig({ verificationTokenHash: tokenHash(token), verificationExpiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() });
await saveAuthConfig({ verificationTokenHash: tokenHash(token), verificationExpiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() });
const link = `${appOrigin(req)}/verify?token=${encodeURIComponent(token)}`;
const text = `Use this link to verify your Calorie AI account email. It expires in 24 hours.\n\n${link}`;
const html = actionEmailHtml('Verify your email', 'Please verify your Calorie AI account email by clicking the button below. This link expires in 24 hours.', link, 'Verify My Email');
@ -692,7 +628,7 @@ async function verifyAccount(req, res) {
const payload = JSON.parse(await readBody(req) || '{}');
const valid = auth.verificationTokenHash && auth.verificationExpiresAt && new Date(auth.verificationExpiresAt).getTime() > Date.now() && safeEqual(auth.verificationTokenHash, tokenHash(payload.token || ''));
if (!valid) return send(res, 400, JSON.stringify({ error: 'Verification link is invalid or expired' }));
saveAuthConfig({ emailVerified: true, verificationTokenHash: undefined, verificationExpiresAt: undefined });
await saveAuthConfig({ emailVerified: true, verificationTokenHash: undefined, verificationExpiresAt: undefined });
send(res, 200, JSON.stringify({ ok: true }));
} catch (error) {
send(res, 400, JSON.stringify({ error: error.message }));
@ -706,8 +642,12 @@ function logout(req, res) {
}
async function start() {
store = await createStore({
dataDir,
legacyFiles: { authFile, modelsFile, plansFile, settingsFile, entriesFile },
});
auth = await loadAuthConfig();
modelConfig = loadModelConfig();
modelConfig = await loadModelConfig();
const app = express();
app.use(express.text({ type: '*/*', limit: '12mb' }));
app.use((req, res, next) => {
@ -746,6 +686,8 @@ async function start() {
app.post('/api/entries/delete', wrap(deleteEntriesRoute));
app.post('/api/entries/restore', wrap(restoreEntryRoute));
app.post('/api/entries/clear-trash', wrap(clearTrashRoute));
app.get('/api/activities', wrap(getActivitiesRoute));
app.post('/api/activities/sync', wrap(syncActivitiesRoute));
app.post('/api/chat', wrap(proxyChat));
app.use((req, res) => {
if (req.method === 'GET' || req.method === 'HEAD') return serveStatic(req, res);

437
web/server/storage.js Normal file
View file

@ -0,0 +1,437 @@
const fs = require('fs');
const path = require('path');
const { Pool } = require('pg');
const defaultSettings = {
visionModel: '',
taskModel: '',
sex: '',
age: '',
heightCm: '',
weightKg: '',
targetWeightKg: '',
goal: 'Lose weight',
calorieTarget: '',
activityLevel: 'Moderate',
pace: 'Steady',
motivation: '',
challenge: '',
trackingStyle: '',
calorieCountingExperience: '',
fastingInterest: '',
};
function readJsonFile(file, fallback) {
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return fallback; }
}
function nowIso() {
return new Date().toISOString();
}
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function maskDatabaseUrl(databaseUrl) {
try {
const url = new URL(databaseUrl);
if (url.password) url.password = '***';
return url.toString();
} catch {
return 'PostgreSQL';
}
}
function normalizePlan(plan) {
return {
id: String(plan.id || ''),
title: String(plan.title || 'Weight-loss plan'),
version: Number(plan.version || 1),
content: String(plan.content || ''),
previousPlanId: plan.previousPlanId || '',
createdAt: plan.createdAt || nowIso(),
};
}
function normalizeEntry(entry, trashedAt = '') {
const payload = { ...entry };
if (trashedAt) payload.trashedAt = trashedAt;
else delete payload.trashedAt;
return payload;
}
function readSqliteLegacy(sqliteFile) {
if (!fs.existsSync(sqliteFile)) return {};
try {
const { DatabaseSync } = require('node:sqlite');
const db = new DatabaseSync(sqliteFile, { readOnly: true });
const readKey = key => {
try {
const row = db.prepare('SELECT value FROM app_kv WHERE key = ?').get(key);
return row ? JSON.parse(row.value) : undefined;
} catch { return undefined; }
};
const plans = (() => {
try {
return db.prepare('SELECT id, title, version, content, previous_plan_id AS previousPlanId, created_at AS createdAt FROM plans ORDER BY version DESC, created_at DESC').all();
} catch { return []; }
})();
const entries = [];
const trash = [];
try {
for (const row of db.prepare('SELECT payload, trashed_at AS trashedAt FROM meal_entries ORDER BY updated_at DESC').all()) {
const entry = JSON.parse(row.payload);
if (row.trashedAt) trash.push(normalizeEntry(entry, row.trashedAt));
else entries.push(normalizeEntry(entry, ''));
}
} catch {}
db.close();
return {
authConfig: readKey('auth_config'),
modelConfig: readKey('model_config'),
settings: readKey('settings'),
selectedPlanId: readKey('selected_plan_id'),
plans,
entries,
trash,
};
} catch {
return {};
}
}
async function connectWithRetry(pool, attempts = 40) {
let lastError;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
await pool.query('SELECT 1');
return;
} catch (error) {
lastError = error;
await delay(Math.min(3000, attempt * 250));
}
}
throw lastError;
}
async function createStore({
dataDir,
databaseUrl = process.env.CALORIE_AI_DATABASE_URL || process.env.DATABASE_URL || 'postgresql://calorie_ai@postgres:5432/calorie_ai',
legacyFiles = {},
sqliteFile = path.join(dataDir, 'calorie-ai.sqlite'),
}) {
fs.mkdirSync(dataDir, { recursive: true });
const pool = new Pool({ connectionString: databaseUrl });
await connectWithRetry(pool);
await pool.query(`
CREATE TABLE IF NOT EXISTS app_kv (
key TEXT PRIMARY KEY,
value JSONB NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS plans (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
version INTEGER NOT NULL,
content TEXT NOT NULL,
previous_plan_id TEXT,
created_at TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS meal_entries (
id TEXT PRIMARY KEY,
payload JSONB NOT NULL,
trashed_at TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS activities (
id TEXT PRIMARY KEY,
source TEXT NOT NULL,
source_name TEXT NOT NULL DEFAULT '',
type TEXT NOT NULL DEFAULT '',
title TEXT NOT NULL DEFAULT '',
start_at TEXT NOT NULL,
end_at TEXT NOT NULL DEFAULT '',
calories DOUBLE PRECISION NOT NULL DEFAULT 0,
steps BIGINT NOT NULL DEFAULT 0,
distance_meters DOUBLE PRECISION NOT NULL DEFAULT 0,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
synced_at TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`);
async function getKey(key, fallback) {
const result = await pool.query('SELECT value FROM app_kv WHERE key = $1', [key]);
return result.rows[0]?.value ?? fallback;
}
async function setKey(key, value) {
await pool.query(`
INSERT INTO app_kv (key, value, updated_at) VALUES ($1, $2::jsonb, now())
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
`, [key, JSON.stringify(value)]);
}
async function hasKey(key) {
const result = await pool.query('SELECT 1 FROM app_kv WHERE key = $1', [key]);
return result.rowCount > 0;
}
async function savePlanRow(plan) {
const normalized = normalizePlan(plan);
await pool.query(`
INSERT INTO plans (id, title, version, content, previous_plan_id, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, now())
ON CONFLICT(id) DO UPDATE SET
title = excluded.title,
version = excluded.version,
content = excluded.content,
previous_plan_id = excluded.previous_plan_id,
created_at = excluded.created_at,
updated_at = excluded.updated_at
`, [
normalized.id,
normalized.title,
normalized.version,
normalized.content,
normalized.previousPlanId || null,
normalized.createdAt,
]);
return normalized.id;
}
async function saveEntryRow(entry, trashedAt = '') {
const payload = normalizeEntry(entry, trashedAt);
await pool.query(`
INSERT INTO meal_entries (id, payload, trashed_at, updated_at) VALUES ($1, $2::jsonb, $3, now())
ON CONFLICT(id) DO UPDATE SET payload = excluded.payload, trashed_at = excluded.trashed_at, updated_at = excluded.updated_at
`, [String(payload.id), JSON.stringify(payload), trashedAt || null]);
}
async function migrateLegacy() {
if (await getKey('legacy_migration_done', false)) return;
const authMissing = !await hasKey('auth_config');
const modelsMissing = !await hasKey('model_config');
const settingsMissing = !await hasKey('settings');
const planCount = await pool.query('SELECT COUNT(*)::int AS count FROM plans');
const entryCount = await pool.query('SELECT COUNT(*)::int AS count FROM meal_entries');
if (!authMissing && !modelsMissing && !settingsMissing && planCount.rows[0].count > 0 && entryCount.rows[0].count > 0) {
await setKey('legacy_migration_done', true);
return;
}
const sqlite = readSqliteLegacy(sqliteFile);
if (authMissing) await setKey('auth_config', sqlite.authConfig || readJsonFile(legacyFiles.authFile, {}));
if (modelsMissing) await setKey('model_config', sqlite.modelConfig || readJsonFile(legacyFiles.modelsFile, {}));
if (settingsMissing) await setKey('settings', sqlite.settings || readJsonFile(legacyFiles.settingsFile, defaultSettings));
if (planCount.rows[0].count === 0) {
const jsonPlans = readJsonFile(legacyFiles.plansFile, {});
const plans = sqlite.plans?.length ? sqlite.plans : Array.isArray(jsonPlans.plans) ? jsonPlans.plans : [];
for (const plan of plans) if (plan && plan.id && plan.content) await savePlanRow(plan);
const selectedPlanId = sqlite.selectedPlanId || jsonPlans.selectedPlanId || '';
if (selectedPlanId) await setKey('selected_plan_id', selectedPlanId);
}
if (entryCount.rows[0].count === 0) {
const jsonEntries = readJsonFile(legacyFiles.entriesFile, {});
const entries = sqlite.entries?.length ? sqlite.entries : Array.isArray(jsonEntries.entries) ? jsonEntries.entries : [];
const trash = sqlite.trash?.length ? sqlite.trash : Array.isArray(jsonEntries.trash) ? jsonEntries.trash : [];
for (const entry of entries) if (entry && entry.id) await saveEntryRow(entry, '');
for (const entry of trash) if (entry && entry.id) await saveEntryRow(entry, entry.trashedAt || nowIso());
}
await setKey('legacy_migration_done', true);
}
async function getPlans() {
const result = await pool.query(`
SELECT id, title, version, content, previous_plan_id, created_at
FROM plans
ORDER BY version DESC, created_at DESC
`);
const plans = result.rows.map(row => {
const plan = {
id: row.id,
title: row.title,
version: row.version,
content: row.content,
createdAt: row.created_at,
};
if (row.previous_plan_id) plan.previousPlanId = row.previous_plan_id;
return plan;
});
const selectedPlanId = await getKey('selected_plan_id', '') || plans[0]?.id || '';
return { plans, selectedPlanId };
}
async function savePlan(plan) {
const id = await savePlanRow(plan);
await setKey('selected_plan_id', id);
return getPlans();
}
async function selectPlan(id) {
await setKey('selected_plan_id', id || '');
return getPlans();
}
async function planExists(id) {
const result = await pool.query('SELECT 1 FROM plans WHERE id = $1', [id]);
return result.rowCount > 0;
}
async function deletePlan(id) {
await pool.query('DELETE FROM plans WHERE id = $1', [id]);
const state = await getPlans();
if (state.selectedPlanId === id) await setKey('selected_plan_id', state.plans[0]?.id || '');
return getPlans();
}
async function getEntries() {
const result = await pool.query('SELECT payload, trashed_at FROM meal_entries ORDER BY updated_at DESC');
const entries = [];
const trash = [];
for (const row of result.rows) {
if (row.trashed_at) trash.push(normalizeEntry(row.payload, row.trashed_at));
else entries.push(normalizeEntry(row.payload, ''));
}
return { entries, trash };
}
async function upsertMealEntry(entry) {
await saveEntryRow(entry, '');
return getEntries();
}
async function moveEntriesToTrash(ids) {
const result = await pool.query('SELECT payload FROM meal_entries WHERE trashed_at IS NULL AND id = ANY($1::text[])', [ids]);
const trashedAt = nowIso();
for (const row of result.rows) await saveEntryRow(row.payload, trashedAt);
return getEntries();
}
async function restoreEntry(id) {
const result = await pool.query('SELECT payload FROM meal_entries WHERE id = $1 AND trashed_at IS NOT NULL', [id]);
if (!result.rows[0]) return null;
await saveEntryRow(result.rows[0].payload, '');
return getEntries();
}
async function clearTrash() {
await pool.query('DELETE FROM meal_entries WHERE trashed_at IS NOT NULL');
return getEntries();
}
function normalizeActivity(activity, fallbackSource = '') {
const startAt = activity.startAt || activity.date || nowIso();
const source = String(activity.source || fallbackSource || 'external');
return {
id: String(activity.id || `${source}-${startAt}`),
source,
sourceName: String(activity.sourceName || activity.source_name || source),
type: String(activity.type || 'Activity'),
title: String(activity.title || activity.type || 'Activity'),
startAt: String(startAt),
endAt: String(activity.endAt || activity.end_at || startAt),
calories: Number(activity.calories || 0),
steps: Number(activity.steps || 0),
distanceMeters: Number(activity.distanceMeters || activity.distance_meters || 0),
metadata: activity.metadata && typeof activity.metadata === 'object' ? activity.metadata : {},
syncedAt: String(activity.syncedAt || nowIso()),
};
}
async function getActivities(limit = 500) {
const result = await pool.query(`
SELECT id, source, source_name, type, title, start_at, end_at, calories, steps, distance_meters, metadata, synced_at
FROM activities
ORDER BY start_at DESC
LIMIT $1
`, [limit]);
return {
activities: result.rows.map(row => ({
id: row.id,
source: row.source,
sourceName: row.source_name,
type: row.type,
title: row.title,
startAt: row.start_at,
endAt: row.end_at,
calories: Number(row.calories || 0),
steps: Number(row.steps || 0),
distanceMeters: Number(row.distance_meters || 0),
metadata: row.metadata || {},
syncedAt: row.synced_at,
})),
};
}
async function upsertActivities(activities, source = '') {
for (const item of activities) {
const activity = normalizeActivity(item, source);
await pool.query(`
INSERT INTO activities (id, source, source_name, type, title, start_at, end_at, calories, steps, distance_meters, metadata, synced_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12, now())
ON CONFLICT(id) DO UPDATE SET
source = excluded.source,
source_name = excluded.source_name,
type = excluded.type,
title = excluded.title,
start_at = excluded.start_at,
end_at = excluded.end_at,
calories = excluded.calories,
steps = excluded.steps,
distance_meters = excluded.distance_meters,
metadata = excluded.metadata,
synced_at = excluded.synced_at,
updated_at = excluded.updated_at
`, [
activity.id,
activity.source,
activity.sourceName,
activity.type,
activity.title,
activity.startAt,
activity.endAt,
activity.calories,
activity.steps,
activity.distanceMeters,
JSON.stringify(activity.metadata),
activity.syncedAt,
]);
}
return getActivities();
}
await migrateLegacy();
return {
databaseLabel: maskDatabaseUrl(databaseUrl),
getAuthConfig: () => getKey('auth_config', {}),
saveAuthConfig: config => setKey('auth_config', config),
getModelConfig: () => getKey('model_config', {}),
saveModelConfig: config => setKey('model_config', config),
getSettings: async () => ({ ...defaultSettings, ...await getKey('settings', defaultSettings) }),
saveSettings: settings => setKey('settings', settings),
getPlans,
savePlan,
selectPlan,
planExists,
deletePlan,
getEntries,
upsertMealEntry,
moveEntriesToTrash,
restoreEntry,
clearTrash,
getActivities,
upsertActivities,
};
}
module.exports = { createStore, defaultSettings };

View file

@ -0,0 +1,97 @@
<script>
import { onDestroy, onMount, tick } from 'svelte';
export let week = [];
let calorieCanvas;
let produceCanvas;
let chartTimer;
onMount(scheduleCharts);
onDestroy(() => clearTimeout(chartTimer));
$: scheduleCharts(week);
function scheduleCharts() {
clearTimeout(chartTimer);
chartTimer = setTimeout(drawCharts, 0);
}
async function drawCharts() {
await tick();
drawBars(calorieCanvas, week, 'calories');
drawBars(produceCanvas, week, 'produce');
}
function drawBars(canvas, days, mode) {
if (!canvas) return;
const dpr = window.devicePixelRatio || 1;
const cssW = canvas.offsetWidth || 400;
const cssH = canvas.offsetHeight || 200;
canvas.width = cssW * dpr;
canvas.height = cssH * dpr;
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
const values = days.map(day => mode === 'calories' ? day.calories : day.fruit + day.vegetables);
const max = Math.max(1, ...values);
const left = 4;
const bottom = cssH - 32;
const barHeight = cssH - 52;
const gap = 8;
const bar = (cssW - left * 2 - gap * 6) / 7;
ctx.textAlign = 'center';
days.forEach((day, index) => {
const x = left + index * (bar + gap);
const h = values[index] / max * barHeight;
ctx.fillStyle = '#e2e8f0';
roundRect(ctx, x, bottom - barHeight, bar, barHeight, 6);
ctx.fillStyle = mode === 'calories' ? '#2563eb' : '#f59e0b';
roundRect(ctx, x, bottom - h, bar, h, 6);
if (mode === 'produce' && day.vegetables > 0) {
const vegH = day.vegetables / max * barHeight;
ctx.fillStyle = '#10b981';
roundRect(ctx, x + bar * 0.52, bottom - vegH, bar * 0.48, vegH, 5);
}
ctx.fillStyle = '#64748b';
ctx.font = `${Math.max(10, Math.min(13, bar * 0.55))}px Inter, sans-serif`;
ctx.fillText(day.label, x + bar / 2, bottom + 20);
});
}
function roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.roundRect(x, y, w, Math.max(1, h), r);
ctx.fill();
}
</script>
<section class="space-y-4">
<div>
<h2 class="text-xl font-bold">Analytics</h2>
<p class="text-sm text-slate-500">Seven-day calorie and produce trends.</p>
</div>
<div class="grid gap-4 xl:grid-cols-2">
<section class="card">
<h3 class="card-title">Calories - last 7 days</h3>
<div class="p-4" style="height:220px">
<canvas id="calorieChart" bind:this={calorieCanvas} class="w-full h-full"></canvas>
</div>
</section>
<section class="card">
<h3 class="card-title">Fruit and vegetables</h3>
<div class="p-4" style="height:220px">
<canvas id="produceChart" bind:this={produceCanvas} class="w-full h-full"></canvas>
</div>
</section>
</div>
<div class="grid gap-3 sm:grid-cols-3">
{#each week.slice().reverse().slice(0, 3) as day}
<article class="card p-4">
<div class="text-xs font-semibold text-slate-400">{day.date}</div>
<div class="mt-1 text-xl font-black text-slate-800">{day.calories} kcal</div>
<div class="text-xs text-slate-500">P {day.protein}g · C {day.carbs}g · F {day.fat}g</div>
<div class="text-xs text-slate-400">Fruit {day.fruit.toFixed(1)} · Veg {day.vegetables.toFixed(1)}</div>
</article>
{/each}
</div>
</section>

View file

@ -1,39 +1,17 @@
<script>
import { onMount, tick } from 'svelte';
import Icon from './Icon.svelte';
import Field from './Field.svelte';
import Stat from './Stat.svelte';
import { onMount } from 'svelte';
import { registerPlugin } from '@capacitor/core';
import AppShell from './AppShell.svelte';
import DashboardPage from './DashboardPage.svelte';
import AnalyticsPage from './AnalyticsPage.svelte';
import MealForm from './MealForm.svelte';
import DiaryPage from './DiaryPage.svelte';
import SettingsPage from './SettingsPage.svelte';
import PlansPage from './PlansPage.svelte';
import { defaultSettings, fallbackModels, mealTypes, pages } from './appConfig.js';
import { calculateCaloriePlan, planSummary } from './calorie.js';
const pages = [
{ id: 'dashboard', label: 'Dashboard', icon: 'dashboard', group: 'Tracker' },
{ id: 'log', label: 'Log meal', icon: 'plus', group: 'Tracker' },
{ id: 'analytics', label: 'Analytics', icon: 'analytics', group: 'Tracker' },
{ id: 'diary', label: 'Diary', icon: 'diary', group: 'Tracker' },
{ id: 'trash', label: 'Trash', icon: 'trash', group: 'Tracker' },
{ id: 'plans', label: 'Plans', icon: 'plans', group: 'Tracker' },
{ id: 'settings', label: 'Settings', icon: 'settings', group: 'Admin' },
];
const mealTypes = ['Breakfast', 'Lunch', 'Dinner', 'Snack', 'Other'];
const fallbackModels = [
{ id: 'claude-haiku-4.5', name: 'Claude Haiku 4.5' },
{ id: 'claude-sonnet-4.6', name: 'Claude Sonnet 4.6' },
{ id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash' },
{ id: 'gpt-4o-mini', name: 'GPT-4o mini' },
];
const defaultSettings = {
visionModel: fallbackModels[0].id,
taskModel: fallbackModels[0].id,
heightCm: '',
weightKg: '',
targetWeightKg: '',
calorieTarget: '',
activityLevel: 'Moderate',
pace: 'Steady',
};
const HealthConnect = registerPlugin('HealthConnect');
let loginMode = location.pathname === '/login' || location.pathname === '/reset' || location.pathname === '/verify';
let username = 'admin';
@ -54,12 +32,14 @@
let sidebarCollapsed = false;
let entries = [];
let activities = [];
let models = fallbackModels;
let discoveredModels = [];
let modelSearch = '';
let modelStatus = '';
let modelError = false;
let modelSearching = false;
let modelActionId = '';
let settings = { ...defaultSettings };
let currentPassword = '';
let newPassword = '';
@ -68,6 +48,9 @@
let accountVerified = false;
let accountStatus = '';
let accountError = false;
let healthConnectStatus = 'Health Connect not synced.';
let healthConnectError = false;
let healthConnectSyncing = false;
let passwordStatus = '';
let passwordError = false;
let resetPasswordValue = '';
@ -97,17 +80,20 @@
let selectedEntryIds = [];
let trash = [];
let calorieCanvas;
let produceCanvas;
$: trackerPages = pages.filter(item => item.group === 'Tracker');
$: adminPages = pages.filter(item => item.group === 'Admin');
$: today = totalsFor(todayKey());
$: week = last7Days();
$: weekAverage = Math.round(week.reduce((sum, day) => sum + day.calories, 0) / week.length);
$: produceToday = today.fruit + today.vegetables;
$: todayEntries = entries.filter(e => e.date === todayKey()).sort((a, b) => a.time.localeCompare(b.time));
$: todayActivities = activities.filter(activity => String(activity.startAt || '').slice(0, 10) === todayKey());
$: activityToday = todayActivities.reduce((sum, activity) => ({
calories: sum.calories + Number(activity.calories || 0),
steps: sum.steps + Number(activity.steps || 0),
}), { calories: 0, steps: 0 });
$: macroTotal = Math.max(1, today.protein + today.carbs + today.fat);
$: calculatedCaloriePlan = calculateCaloriePlan(settings);
$: activeCalorieTarget = Number(settings.calorieTarget) || calculatedCaloriePlan?.goalCalories || 0;
$: targetPercent = activeCalorieTarget ? Math.min(100, Math.round(today.calories / activeCalorieTarget * 100)) : 0;
$: diaryRows = entries
.filter(entry => !diaryType || entry.mealType === diaryType)
.filter(entry => !diaryDate || entry.date === diaryDate)
@ -120,22 +106,17 @@
$: 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(), loadModels(), loadPlans()]);
await drawCharts();
await loadSettingsFromServer();
await loadModels();
await Promise.all([loadEntriesFromServer(), loadActivitiesFromServer(), loadPlans()]);
});
$: if (!loginMode) scheduleCharts(entries, page);
let chartTimer;
function scheduleCharts() {
clearTimeout(chartTimer);
chartTimer = setTimeout(drawCharts, 0);
}
function dateKey(date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
@ -264,10 +245,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', {
@ -290,6 +278,37 @@
trash = data.trash || [];
}
async function loadActivitiesFromServer() {
try {
const response = await fetch('/api/activities');
if (response.ok) activities = (await response.json()).activities || [];
} 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) {
plans = config.plans || [];
selectedPlanId = config.selectedPlanId || plans[0]?.id || '';
@ -337,6 +356,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.';
@ -373,43 +406,63 @@
}
async function addModel(model) {
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}.`;
modelActionId = model.id;
modelStatus = `Adding ${model.name || model.id}...`;
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) {
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.';
modelActionId = modelId;
modelStatus = 'Removing model...';
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() {
@ -497,15 +550,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() {
@ -535,6 +605,17 @@
return lines.join('\n');
}
function recentActivitySummary(limit = 10) {
const rows = activities.slice(0, limit).map(activity => {
const label = activity.title || activity.type || 'Activity';
const date = String(activity.startAt || '').slice(0, 10);
const calories = Math.round(Number(activity.calories || 0));
const steps = Number(activity.steps || 0);
return `${date} ${label}: ${calories} active kcal${steps ? `, ${steps} steps` : ''} (${activity.sourceName || activity.source || 'external'})`;
});
return rows.join('; ') || 'none synced yet';
}
async function requestMealEstimate(imageEstimate) {
const todayHistory = buildTodayHistory();
const userContent = [
@ -620,6 +701,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 = '';
@ -657,7 +739,7 @@
if (!existing) return;
const updated = {
...existing,
...estimateDraft,
...normalizeEstimateDraft(),
date: mealDate,
time: mealTime,
mealType,
@ -684,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() {
editingEntryId = '';
estimateDraft = emptyEstimate();
@ -735,18 +858,39 @@
planStatus = 'Generating plan...';
planError = false;
try {
if (!settings.heightCm || !settings.weightKg) throw new Error('Enter height and current weight first.');
if (!settings.sex || !settings.age || !settings.heightCm || !settings.weightKg) throw new Error('Enter sex, age, height, and current weight first.');
const calculatedPlan = calculateCaloriePlan(settings);
if (calculatedPlan && !settings.calorieTarget) settings.calorieTarget = String(calculatedPlan.goalCalories);
await saveSettings();
const content = await chatCompletion({
model: settings.taskModel,
temperature: 0.2,
messages: [
{ role: 'system', content: 'Create safe, concise nutrition and weight-loss guidance. Do not diagnose disease. Recommend medical review for pregnancy, eating disorder history, chronic disease, or aggressive goals. Use plain text section titles and simple lines. Do not use markdown symbols.' },
{ role: 'user', content: `Create a personalized weight-loss plan using these details: height ${settings.heightCm} cm, current weight ${settings.weightKg} kg, target weight ${settings.targetWeightKg || 'not set'} kg, activity ${settings.activityLevel}, pace ${settings.pace}. Include daily calorie range, protein target, habits, weekly review steps, and safety notes. Recent meals: ${entries.slice(-10).map(entry => `${entry.date} ${entry.mealType}: ${entry.mealName || entry.description}, ${entry.calories || 0} kcal`).join('; ') || 'none logged yet'}.` },
{ role: 'system', content: 'Create safe, concise nutrition and weight-management guidance. Do not diagnose disease. Recommend medical review for pregnancy, eating disorder history, chronic disease, or aggressive goals. Use plain text section titles and simple lines. Do not use markdown symbols. Make it feel like a personalized plan built from onboarding answers, not a generic article.' },
{ role: 'user', content: `Create a personalized plan using these details:
Goal: ${settings.goal}
Sex: ${settings.sex}
Age: ${settings.age}
Height: ${settings.heightCm} cm
Current weight: ${settings.weightKg} kg
Target weight: ${settings.targetWeightKg || 'not set'} kg
Activity: ${settings.activityLevel}
Pace: ${settings.pace}
Calculated targets: ${planSummary(settings)}
Motivation: ${settings.motivation || 'not set'}
Biggest challenge: ${settings.challenge || 'not set'}
Tracking style: ${settings.trackingStyle || 'not set'}
Calorie-counting experience: ${settings.calorieCountingExperience || 'not set'}
Fasting interest: ${settings.fastingInterest || 'not set'}
Recent meals: ${entries.slice(-10).map(entry => `${entry.date} ${entry.mealType}: ${entry.mealName || entry.description}, ${entry.calories || 0} kcal`).join('; ') || 'none logged yet'}.
Recent activity: ${recentActivitySummary()}.
Include: daily calorie target, maintenance calories, protein target, meal logging strategy, activity adjustment rules, challenge-specific tactics, weekly review steps, and safety notes.` },
],
});
const version = Math.max(0, ...plans.map(p => Number(p.version || 0))) + 1;
const plan = { id: entryId(), title: 'Weight-loss plan', version, content, createdAt: new Date().toISOString() };
const titlePrefix = settings.goal === 'Lose weight' ? 'Weight-loss' : settings.goal || 'Weight-management';
const plan = { id: entryId(), title: `${titlePrefix} plan ${version}`, version, content, createdAt: new Date().toISOString() };
await savePlan(plan);
planStatus = 'Plan generated.';
} catch (error) {
@ -774,11 +918,11 @@
temperature: 0.2,
messages: [
{ role: 'system', content: 'Update the weight-loss plan using the provided notes and logged meals. Use plain text section titles and simple lines. Do not use markdown symbols. Do not mention chat history.' },
{ role: 'user', content: `Current plan:\n${currentPlan.content}\n\nUpdate note:\n${tuneNote}\n\nRecent meals:\n${entries.slice(-15).map(entry => `${entry.date} ${entry.mealType}: ${entry.mealName || entry.description}, ${entry.calories || 0} kcal`).join('; ') || 'none logged yet'}` },
{ role: 'user', content: `Current plan:\n${currentPlan.content}\n\nUpdate note:\n${tuneNote}\n\nRecent meals:\n${entries.slice(-15).map(entry => `${entry.date} ${entry.mealType}: ${entry.mealName || entry.description}, ${entry.calories || 0} kcal`).join('; ') || 'none logged yet'}\n\nRecent activity:\n${recentActivitySummary(15)}` },
],
});
const version = Math.max(0, ...plans.map(plan => Number(plan.version || 0))) + 1;
const updatedPlan = { id: entryId(), title: 'Weight-loss plan', version, content, createdAt: new Date().toISOString(), previousPlanId: currentPlan.id };
const updatedPlan = { id: entryId(), title: `Updated plan ${version}`, version, content, createdAt: new Date().toISOString(), previousPlanId: currentPlan.id };
await savePlan(updatedPlan);
tuneNote = '';
planStatus = 'Plan updated.';
@ -809,52 +953,6 @@
if (response.ok) applyPlanConfig(await response.json());
}
async function drawCharts() {
await tick();
drawBars(calorieCanvas, week, 'calories');
drawBars(produceCanvas, week, 'produce');
}
function drawBars(canvas, days, mode) {
if (!canvas) return;
const dpr = window.devicePixelRatio || 1;
const cssW = canvas.offsetWidth || 400;
const cssH = canvas.offsetHeight || 200;
canvas.width = cssW * dpr;
canvas.height = cssH * dpr;
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
const values = days.map(day => mode === 'calories' ? day.calories : day.fruit + day.vegetables);
const max = Math.max(1, ...values);
const left = 4;
const bottom = cssH - 32;
const barHeight = cssH - 52;
const gap = 8;
const bar = (cssW - left * 2 - gap * 6) / 7;
ctx.textAlign = 'center';
days.forEach((day, index) => {
const x = left + index * (bar + gap);
const h = values[index] / max * barHeight;
ctx.fillStyle = '#e2e8f0';
roundRect(ctx, x, bottom - barHeight, bar, barHeight, 6);
ctx.fillStyle = mode === 'calories' ? '#2563eb' : '#f59e0b';
roundRect(ctx, x, bottom - h, bar, h, 6);
if (mode === 'produce' && day.vegetables > 0) {
const vegH = day.vegetables / max * barHeight;
ctx.fillStyle = '#10b981';
roundRect(ctx, x + bar * 0.52, bottom - vegH, bar * 0.48, vegH, 5);
}
ctx.fillStyle = '#64748b';
ctx.font = `${Math.max(10, Math.min(13, bar * 0.55))}px Inter, sans-serif`;
ctx.fillText(day.label, x + bar / 2, bottom + 20);
});
}
function roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.roundRect(x, y, w, Math.max(1, h), r);
ctx.fill();
}
</script>
{#if loginMode}
@ -911,113 +1009,20 @@
</div>
</main>
{:else}
<div class="min-h-screen bg-slate-50 text-slate-900 lg:grid lg:grid-cols-[auto_1fr]">
<aside class:translate-x-0={menuOpen} class:collapsed-sidebar={sidebarCollapsed} class="sidebar-shell fixed inset-y-0 left-0 z-40 w-64 -translate-x-full overflow-hidden border-r border-slate-200 bg-white shadow-xl transition-all duration-200 lg:sticky lg:top-0 lg:h-screen lg:translate-x-0 lg:shadow-none lg:w-56">
<div class="sidebar-header flex h-16 items-center justify-between border-b border-slate-200 px-4">
<div class="flex min-w-0 items-center gap-3">
<div class="sidebar-logo grid h-9 w-9 flex-none place-items-center rounded-xl bg-blue-600 text-xs font-black text-white">CA</div>
<div class="sidebar-brand min-w-0">
<div class="truncate text-base font-black text-slate-950">Calorie AI</div>
<div class="text-xs text-slate-500">Food diary</div>
</div>
</div>
<button class="collapse-button hidden h-8 w-8 place-items-center rounded-lg text-slate-400 hover:bg-slate-100 hover:text-slate-700 lg:grid" type="button" on:click={() => sidebarCollapsed = !sidebarCollapsed} aria-label="Collapse menu">
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path fill-rule="evenodd" d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"/></svg>
</button>
<button class="rounded-md px-2.5 py-1 text-xs font-bold text-slate-600 hover:bg-slate-100 lg:hidden" type="button" on:click={() => menuOpen = false}>Close</button>
</div>
<nav class="p-2" aria-label="Main menu">
{#if trackerPages.length}<div class="sidebar-section-label px-3 py-2 text-[10px] font-bold uppercase tracking-widest text-slate-400">Tracker</div>{/if}
{#each trackerPages as item}
<button class:active-tab={page === item.id} class="tab-button" type="button" title={item.label} on:click={() => selectPage(item.id)}>
<span class="tab-icon"><Icon name={item.icon} /></span>
<span class="tab-label">{item.label}</span>
</button>
{/each}
{#if adminPages.length}<div class="sidebar-section-label px-3 py-2 text-[10px] font-bold uppercase tracking-widest text-slate-400">Admin</div>{/if}
{#each adminPages as item}
<button class:active-tab={page === item.id} class="tab-button" type="button" title={item.label} on:click={() => selectPage(item.id)}>
<span class="tab-icon"><Icon name={item.icon} /></span>
<span class="tab-label">{item.label}</span>
</button>
{/each}
</nav>
</aside>
{#if menuOpen}<button class="fixed inset-0 z-30 bg-slate-950/40 lg:hidden" type="button" aria-label="Close menu" on:click={() => menuOpen = false}></button>{/if}
<div class="min-w-0">
<header class="sticky top-0 z-20 border-b border-slate-200 bg-white/95 shadow-sm backdrop-blur">
<div class="flex h-16 items-center justify-between gap-3 px-4 lg:px-6">
<div class="flex min-w-0 items-center gap-3">
<button class="grid h-9 w-9 place-items-center rounded-xl bg-blue-600 text-white hover:bg-blue-700 lg:hidden" type="button" on:click={() => menuOpen = true} aria-label="Open menu">
<svg viewBox="0 0 20 20" fill="currentColor" width="18" height="18"><path fill-rule="evenodd" d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"/></svg>
</button>
<h1 class="truncate text-base font-bold text-slate-900">{pages.find(item => item.id === page)?.label || 'Dashboard'}</h1>
</div>
<button class="rounded-lg border border-slate-200 px-3 py-1.5 text-sm font-semibold text-slate-600 hover:border-slate-300 hover:text-slate-900" type="button" on:click={logout}>Sign out</button>
</div>
</header>
<main class="min-w-0 flex-1 p-4 lg:p-6">
<AppShell {pages} {page} bind:menuOpen bind:sidebarCollapsed {selectPage} {logout}>
{#if page === 'dashboard'}
<section class="space-y-4">
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<article class="sm:col-span-2 rounded-2xl bg-gradient-to-br from-blue-600 to-indigo-700 p-5 text-white shadow-sm">
<div class="text-xs font-semibold uppercase tracking-widest text-blue-200">Today</div>
<div id="todayCalories" class="mt-1 text-4xl font-black tracking-tight">{today.calories} kcal</div>
<div id="todayMacroText" class="mt-2 text-sm font-medium text-blue-100">P {today.protein}g &nbsp;·&nbsp; C {today.carbs}g &nbsp;·&nbsp; F {today.fat}g</div>
<div id="todayProduceText" class="text-sm text-blue-200">Fruit {today.fruit.toFixed(1)} &nbsp;·&nbsp; Veg {today.vegetables.toFixed(1)}</div>
</article>
<Stat label="Meals today" value={today.meals} note={today.meals === 1 ? 'meal logged' : 'meals logged'} />
<Stat label="7-day average" value={`${weekAverage} kcal`} note="Calories per day" />
</div>
<div class="grid gap-4 lg:grid-cols-2">
<section class="card">
<h3 class="card-title">Macros today</h3>
<div class="space-y-3 p-4">
{#each [['Protein', today.protein, '#2563eb', 'bg-blue-500'], ['Carbs', today.carbs, '#f59e0b', 'bg-amber-400'], ['Fat', today.fat, '#7c3aed', 'bg-violet-500']] as [label, val, , cls]}
<div>
<div class="mb-1.5 flex justify-between text-sm">
<span class="font-semibold text-slate-700">{label}</span>
<span class="text-slate-500">{val}g &nbsp;·&nbsp; {Math.round(val / macroTotal * 100)}%</span>
</div>
<div class="h-2.5 overflow-hidden rounded-full bg-slate-100">
<div class="h-full rounded-full transition-all {cls}" style="width:{Math.round(val / macroTotal * 100)}%"></div>
</div>
</div>
{/each}
<div class="border-t border-slate-100 pt-2 text-xs text-slate-400">{today.calories} kcal total &nbsp;·&nbsp; produce {produceToday.toFixed(1)} / 5</div>
</div>
</section>
<section class="card">
<h3 class="card-title">Today's meals</h3>
<div class="divide-y divide-slate-100">
{#each todayEntries as entry}
<div class="flex items-center justify-between px-4 py-3">
<div class="min-w-0">
<div class="truncate text-sm font-semibold text-slate-800">{entry.mealName || entry.description}</div>
<div class="text-xs text-slate-400">{entry.mealType} · {entry.time}</div>
</div>
<div class="ml-3 shrink-0 text-sm font-bold text-slate-600">{entry.calories} kcal</div>
</div>
{:else}
<div class="px-4 py-8 text-center text-sm text-slate-400">No meals logged today</div>
{/each}
</div>
{#if todayEntries.length}
<div class="border-t border-slate-100 px-4 py-2">
<button class="text-xs font-semibold text-blue-600 hover:text-blue-700" type="button" on:click={() => selectPage('log')}>+ Log another meal</button>
</div>
{:else}
<div class="border-t border-slate-100 px-4 py-2">
<button class="text-xs font-semibold text-blue-600 hover:text-blue-700" type="button" on:click={() => selectPage('log')}>Log your first meal today</button>
</div>
{/if}
</section>
</div>
</section>
<DashboardPage
{today}
{activeCalorieTarget}
{targetPercent}
{macroTotal}
{produceToday}
{todayEntries}
{todayActivities}
{activityToday}
{weekAverage}
{selectPage}
/>
{:else if page === 'log'}
<MealForm
@ -1035,41 +1040,13 @@
bind:estimateDraft
{chooseImage}
{saveMeal}
{saveManualMeal}
{saveMealEdits}
{cancelEdit}
/>
{:else if page === 'analytics'}
<section class="space-y-4">
<div>
<h2 class="text-xl font-bold">Analytics</h2>
<p class="text-sm text-slate-500">Seven-day calorie and produce trends.</p>
</div>
<div class="grid gap-4 xl:grid-cols-2">
<section class="card">
<h3 class="card-title">Calories — last 7 days</h3>
<div class="p-4" style="height:220px">
<canvas id="calorieChart" bind:this={calorieCanvas} class="w-full h-full"></canvas>
</div>
</section>
<section class="card">
<h3 class="card-title">Fruit and vegetables</h3>
<div class="p-4" style="height:220px">
<canvas id="produceChart" bind:this={produceCanvas} class="w-full h-full"></canvas>
</div>
</section>
</div>
<div class="grid gap-3 sm:grid-cols-3">
{#each week.slice().reverse().slice(0, 3) as day}
<article class="card p-4">
<div class="text-xs font-semibold text-slate-400">{day.date}</div>
<div class="mt-1 text-xl font-black text-slate-800">{day.calories} kcal</div>
<div class="text-xs text-slate-500">P {day.protein}g · C {day.carbs}g · F {day.fat}g</div>
<div class="text-xs text-slate-400">Fruit {day.fruit.toFixed(1)} · Veg {day.vegetables.toFixed(1)}</div>
</article>
{/each}
</div>
</section>
<AnalyticsPage {week} />
{:else if page === 'diary'}
<DiaryPage
@ -1128,10 +1105,14 @@
{modelStatus}
{modelError}
{modelSearching}
{modelActionId}
bind:accountEmail
{accountVerified}
{accountStatus}
{accountError}
{healthConnectStatus}
{healthConnectError}
{healthConnectSyncing}
{passwordStatus}
{passwordError}
{resetPasswordValue}
@ -1145,23 +1126,10 @@
{removeModel}
{saveAccountEmail}
{sendVerificationEmail}
{syncHealthConnect}
{changePassword}
{resetPassword}
/>
{/if}
</main>
</div>
</div>
</AppShell>
{/if}
<style>
.tab-button { display: flex; width: 100%; align-items: center; gap: 0.6rem; border-left: 3px solid transparent; border-radius: 0.5rem; padding: 0.55rem 0.85rem; text-align: left; font-size: 0.875rem; font-weight: 600; color: #64748b; transition: background 0.1s, color 0.1s; }
.tab-button:hover { background: #f8fafc; color: #0f172a; }
.active-tab { border-left-color: #2563eb; background: #eff6ff; color: #2563eb; }
.tab-icon { display: flex; width: 1.25rem; height: 1.25rem; flex: 0 0 1.25rem; align-items: center; justify-content: center; }
:global(.collapsed-sidebar) { width: 4.25rem !important; }
:global(.collapsed-sidebar .sidebar-header) { justify-content: center; padding-left: 0; padding-right: 0; }
:global(.collapsed-sidebar .tab-label), :global(.collapsed-sidebar .sidebar-section-label), :global(.collapsed-sidebar .sidebar-brand) { display: none; }
:global(.collapsed-sidebar .tab-button) { justify-content: center; padding-left: 0; padding-right: 0; }
:global(.collapsed-sidebar .collapse-button) { display: grid; }
</style>

79
web/src/AppShell.svelte Normal file
View file

@ -0,0 +1,79 @@
<script>
import Icon from './Icon.svelte';
export let pages = [];
export let page = 'dashboard';
export let menuOpen = false;
export let sidebarCollapsed = false;
export let selectPage;
export let logout;
$: trackerPages = pages.filter(item => item.group === 'Tracker');
$: adminPages = pages.filter(item => item.group === 'Admin');
$: pageLabel = pages.find(item => item.id === page)?.label || 'Dashboard';
</script>
<div class="min-h-screen bg-slate-50 text-slate-900 lg:grid lg:grid-cols-[auto_1fr]">
<aside class:translate-x-0={menuOpen} class:collapsed-sidebar={sidebarCollapsed} class="sidebar-shell fixed inset-y-0 left-0 z-40 w-64 -translate-x-full overflow-hidden border-r border-slate-200 bg-white shadow-xl transition-all duration-200 lg:sticky lg:top-0 lg:h-screen lg:translate-x-0 lg:shadow-none lg:w-56">
<div class="sidebar-header flex h-16 items-center justify-between border-b border-slate-200 px-4">
<div class="flex min-w-0 items-center gap-3">
<div class="sidebar-logo grid h-9 w-9 flex-none place-items-center rounded-xl bg-blue-600 text-xs font-black text-white">CA</div>
<div class="sidebar-brand min-w-0">
<div class="truncate text-base font-black text-slate-950">Calorie AI</div>
<div class="text-xs text-slate-500">Food diary</div>
</div>
</div>
<button class="collapse-button hidden h-8 w-8 place-items-center rounded-lg text-slate-400 hover:bg-slate-100 hover:text-slate-700 lg:grid" type="button" on:click={() => sidebarCollapsed = !sidebarCollapsed} aria-label="Collapse menu">
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path fill-rule="evenodd" d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"/></svg>
</button>
<button class="rounded-md px-2.5 py-1 text-xs font-bold text-slate-600 hover:bg-slate-100 lg:hidden" type="button" on:click={() => menuOpen = false}>Close</button>
</div>
<nav class="p-2" aria-label="Main menu">
{#if trackerPages.length}<div class="sidebar-section-label px-3 py-2 text-[10px] font-bold uppercase tracking-widest text-slate-400">Tracker</div>{/if}
{#each trackerPages as item}
<button class:active-tab={page === item.id} class="tab-button" type="button" title={item.label} on:click={() => selectPage(item.id)}>
<span class="tab-icon"><Icon name={item.icon} /></span>
<span class="tab-label">{item.label}</span>
</button>
{/each}
{#if adminPages.length}<div class="sidebar-section-label px-3 py-2 text-[10px] font-bold uppercase tracking-widest text-slate-400">Admin</div>{/if}
{#each adminPages as item}
<button class:active-tab={page === item.id} class="tab-button" type="button" title={item.label} on:click={() => selectPage(item.id)}>
<span class="tab-icon"><Icon name={item.icon} /></span>
<span class="tab-label">{item.label}</span>
</button>
{/each}
</nav>
</aside>
{#if menuOpen}<button class="fixed inset-0 z-30 bg-slate-950/40 lg:hidden" type="button" aria-label="Close menu" on:click={() => menuOpen = false}></button>{/if}
<div class="min-w-0">
<header class="sticky top-0 z-20 border-b border-slate-200 bg-white/95 shadow-sm backdrop-blur">
<div class="flex h-16 items-center justify-between gap-3 px-4 lg:px-6">
<div class="flex min-w-0 items-center gap-3">
<button class="grid h-9 w-9 place-items-center rounded-xl bg-blue-600 text-white hover:bg-blue-700 lg:hidden" type="button" on:click={() => menuOpen = true} aria-label="Open menu">
<svg viewBox="0 0 20 20" fill="currentColor" width="18" height="18"><path fill-rule="evenodd" d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"/></svg>
</button>
<h1 class="truncate text-base font-bold text-slate-900">{pageLabel}</h1>
</div>
<button class="rounded-lg border border-slate-200 px-3 py-1.5 text-sm font-semibold text-slate-600 hover:border-slate-300 hover:text-slate-900" type="button" on:click={logout}>Sign out</button>
</div>
</header>
<main class="min-w-0 flex-1 p-4 lg:p-6">
<slot />
</main>
</div>
</div>
<style>
.tab-button { display: flex; width: 100%; align-items: center; gap: 0.6rem; border-left: 3px solid transparent; border-radius: 0.5rem; padding: 0.55rem 0.85rem; text-align: left; font-size: 0.875rem; font-weight: 600; color: #64748b; transition: background 0.1s, color 0.1s; }
.tab-button:hover { background: #f8fafc; color: #0f172a; }
.active-tab { border-left-color: #2563eb; background: #eff6ff; color: #2563eb; }
.tab-icon { display: flex; width: 1.25rem; height: 1.25rem; flex: 0 0 1.25rem; align-items: center; justify-content: center; }
.collapsed-sidebar { width: 4.25rem !important; }
.collapsed-sidebar .sidebar-header { justify-content: center; padding-left: 0; padding-right: 0; }
.collapsed-sidebar .tab-label, .collapsed-sidebar .sidebar-section-label, .collapsed-sidebar .sidebar-brand { display: none; }
.collapsed-sidebar .tab-button { justify-content: center; padding-left: 0; padding-right: 0; }
.collapsed-sidebar .collapse-button { display: grid; }
</style>

View file

@ -0,0 +1,109 @@
<script>
import Stat from './Stat.svelte';
export let today;
export let activeCalorieTarget;
export let targetPercent;
export let macroTotal;
export let produceToday;
export let todayEntries = [];
export let todayActivities = [];
export let activityToday;
export let weekAverage;
export let selectPage;
</script>
<section class="space-y-4">
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
<article class="sm:col-span-2 rounded-2xl bg-gradient-to-br from-blue-600 to-indigo-700 p-5 text-white shadow-sm">
<div class="text-xs font-semibold uppercase tracking-widest text-blue-200">Today</div>
<div id="todayCalories" class="mt-1 text-4xl font-black tracking-tight">{today.calories} kcal</div>
{#if activeCalorieTarget}
<div class="mt-2 h-2 overflow-hidden rounded-full bg-white/20"><div class="h-full rounded-full bg-white" style="width:{targetPercent}%"></div></div>
<div class="mt-1 text-xs font-semibold text-blue-100">{Math.max(0, activeCalorieTarget - today.calories)} kcal left of {activeCalorieTarget}</div>
{/if}
<div id="todayMacroText" class="mt-2 text-sm font-medium text-blue-100">P {today.protein}g &nbsp;·&nbsp; C {today.carbs}g &nbsp;·&nbsp; F {today.fat}g</div>
<div id="todayProduceText" class="text-sm text-blue-200">Fruit {today.fruit.toFixed(1)} &nbsp;·&nbsp; Veg {today.vegetables.toFixed(1)}</div>
</article>
<Stat label="Meals today" value={today.meals} note={today.meals === 1 ? 'meal logged' : 'meals logged'} />
<Stat label="7-day average" value={`${weekAverage} kcal`} note="Calories per day" />
<Stat label="Activity today" value={`${Math.round(activityToday.calories)} kcal`} note={`${activityToday.steps} steps`} />
</div>
<div class="grid gap-4 lg:grid-cols-2">
<section class="card">
<h3 class="card-title">Macros today</h3>
<div class="space-y-3 p-4">
{#each [['Protein', today.protein, 'bg-blue-500'], ['Carbs', today.carbs, 'bg-amber-400'], ['Fat', today.fat, 'bg-violet-500']] as [label, val, cls]}
<div>
<div class="mb-1.5 flex justify-between text-sm">
<span class="font-semibold text-slate-700">{label}</span>
<span class="text-slate-500">{val}g &nbsp;·&nbsp; {Math.round(val / macroTotal * 100)}%</span>
</div>
<div class="h-2.5 overflow-hidden rounded-full bg-slate-100">
<div class="h-full rounded-full transition-all {cls}" style="width:{Math.round(val / macroTotal * 100)}%"></div>
</div>
</div>
{/each}
<div class="border-t border-slate-100 pt-2 text-xs text-slate-400">{today.calories} kcal total &nbsp;·&nbsp; produce {produceToday.toFixed(1)} / 5</div>
</div>
</section>
<section class="card">
<h3 class="card-title">Today's meals</h3>
<div class="divide-y divide-slate-100">
{#each todayEntries as entry}
<div class="flex items-center justify-between px-4 py-3">
<div class="min-w-0">
<div class="truncate text-sm font-semibold text-slate-800">{entry.mealName || entry.description}</div>
<div class="text-xs text-slate-400">{entry.mealType} · {entry.time}</div>
</div>
<div class="ml-3 shrink-0 text-sm font-bold text-slate-600">{entry.calories} kcal</div>
</div>
{:else}
<div class="px-4 py-8 text-center text-sm text-slate-400">No meals logged today</div>
{/each}
</div>
{#if todayEntries.length}
<div class="border-t border-slate-100 px-4 py-2">
<button class="text-xs font-semibold text-blue-600 hover:text-blue-700" type="button" on:click={() => selectPage('log')}>+ Log another meal</button>
</div>
{:else}
<div class="border-t border-slate-100 px-4 py-2">
<button class="text-xs font-semibold text-blue-600 hover:text-blue-700" type="button" on:click={() => selectPage('log')}>Log your first meal today</button>
</div>
{/if}
</section>
<section class="card lg:col-span-2">
<h3 class="card-title">Synced activity</h3>
<div class="divide-y divide-slate-100">
{#each todayActivities.slice(0, 5) as activity}
<div class="flex items-center justify-between px-4 py-3">
<div class="min-w-0">
<div class="truncate text-sm font-semibold text-slate-800">{activity.title || activity.type || 'Activity'}</div>
<div class="text-xs text-slate-400">{activity.sourceName || activity.source || 'External'} · {String(activity.startAt || '').slice(11, 16) || 'All day'}</div>
</div>
<div class="ml-3 shrink-0 text-right text-sm font-bold text-slate-600">
{Math.round(Number(activity.calories || 0))} kcal
{#if activity.steps}<div class="text-xs font-medium text-slate-400">{activity.steps} steps</div>{/if}
</div>
</div>
{:else}
<div class="grid gap-4 p-4 md:grid-cols-[1fr_1.1fr] md:items-center">
<div class="rounded-2xl bg-blue-50 p-4 text-left">
<div class="text-xs font-black uppercase tracking-widest text-blue-700">Connect Garmin</div>
<h4 class="mt-1 text-lg font-black text-slate-950">Use the Android app to sync your watch.</h4>
<p class="mt-2 text-sm leading-6 text-slate-600">The web dashboard shows synced activity, but Garmin pairing runs through Garmin Connect and Android Health Connect.</p>
</div>
<ol class="grid gap-2 text-sm leading-6 text-slate-600">
<li><strong class="text-slate-900">1.</strong> Sync your watch in Garmin Connect on Android.</li>
<li><strong class="text-slate-900">2.</strong> In Garmin Connect, enable sharing to Health Connect.</li>
<li><strong class="text-slate-900">3.</strong> Open Calorie AI on Android and tap Settings -> Sync Health Connect.</li>
</ol>
</div>
{/each}
</div>
</section>
</div>
</section>

View file

@ -82,10 +82,6 @@
<span class="font-semibold text-slate-800">{entry.mealName || entry.description}</span>
<span class="ml-2 text-sm font-bold text-slate-600">{entry.calories} kcal</span>
</div>
<div class="flex shrink-0 gap-1">
<button class="rounded px-2 py-0.5 text-xs font-semibold text-blue-600 hover:bg-blue-50" type="button" on:click={() => editEntry(entry)}>Edit</button>
<button class="rounded px-2 py-0.5 text-xs font-semibold text-red-500 hover:bg-red-50" type="button" on:click={() => moveEntriesToTrash([entry.id])}>Delete</button>
</div>
</div>
<div class="text-sm text-slate-500">{entry.date} {entry.time} · {entry.mealType}</div>
<div class="mt-0.5 text-xs text-slate-400">P {entry.proteinGrams}g · C {entry.carbsGrams}g · F {entry.fatGrams}g · Fruit {Number(entry.fruitServings || 0).toFixed(1)} · Veg {Number(entry.vegetableServings || 0).toFixed(1)}</div>

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>
@ -36,20 +37,18 @@
<div class="flex gap-2">
<label class="btn-secondary flex cursor-pointer items-center gap-2">
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path d="M2 6a2 2 0 012-2h.5l1.207-1.207A1 1 0 016.414 2.5h7.172a1 1 0 01.707.293L15.5 4H16a2 2 0 012 2v8a2 2 0 01-2 2H4a2 2 0 01-2-2V6zm8 1a3 3 0 100 6 3 3 0 000-6z"/></svg>
Camera
<input type="file" accept="image/*" capture="environment" class="sr-only" on:change={chooseImage}>
</label>
<label class="btn-secondary flex cursor-pointer items-center gap-2">
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path fill-rule="evenodd" d="M4 3a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V5a2 2 0 00-2-2H4zm12 12H4l4-8 3 6 2-4 3 6z" clip-rule="evenodd"/></svg>
Gallery
<input type="file" accept="image/*" class="sr-only" on:change={chooseImage}>
Choose photo
<input id="image" type="file" accept="image/*" class="sr-only" on:change={chooseImage}>
</label>
</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 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>
@ -59,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>

View file

@ -1,5 +1,6 @@
<script>
import Field from './Field.svelte';
import { calculateCaloriePlan, planSummary } from './calorie.js';
export let settings;
export let plans = [];
@ -13,40 +14,67 @@
export let selectPlan;
export let deletePlan;
$: selectedPlan = plans.find(plan => plan.id === selectedPlanId) || plans[0];
let faqOpen = null;
const sexOptions = ['Female', 'Male'];
const goalOptions = ['Lose weight', 'Maintain weight', 'Gain weight', 'Build muscle'];
const motivationOptions = ['Improve my overall health', 'Feel more confident', 'Increase my fitness level', 'Prepare for an event', 'Improve my relationship with food'];
const challengeOptions = ['Resisting cravings', 'Staying motivated', 'Reducing portion sizes', 'Knowing what to eat', 'Being too busy'];
const trackingOptions = ['Photo log meals', 'Log before eating', 'Meal prep and plan ahead', 'Track calories closely', 'Build a streak'];
const calorieExperienceOptions = ['New to calorie counting', 'Tried before', 'Experienced'];
const fastingOptions = ['Interested', 'Tried it before', 'Not interested'];
const faqs = [
{
q: 'How does the AI build my plan?',
a: 'It reads your height, weight, target, activity level, and pace, then looks at your most recent logged meals to understand your actual eating patterns. The result is a calorie range, protein target, and practical habits tailored to you.',
q: 'What changes when I generate a new plan?',
a: 'A new saved version is created. Your older versions stay in Plan history, so you can switch back later.',
},
{
q: 'How often should I update my plan?',
a: 'Update it whenever something changes — a new weigh-in, a shift in activity, or after a few weeks of logging. Use the fine-tune box to tell the AI what changed and it will revise the plan without starting from scratch.',
},
{
q: 'Is this medical or clinical advice?',
a: 'No. This is an AI-generated nutrition estimate based on self-reported data. It is not a substitute for advice from a registered dietitian or doctor. If you have a medical condition, are pregnant, or have a history of disordered eating, consult a qualified health professional first.',
q: 'What should I write in the update box?',
a: 'Use short real-world notes: hunger, weight trend, steps, workouts, missed meals, cravings, sleep, schedule changes, or anything that made the plan hard to follow.',
},
{
q: 'Are my fine-tune notes stored?',
a: 'No. Notes you add in the fine-tune box are sent to the AI to update the plan and then discarded. Only the resulting plan text is saved.',
a: 'No. Notes are sent to the AI to create the next plan version, then discarded. Only the resulting plan text is saved.',
},
{
q: 'Can I go back to an older plan?',
a: 'Yes. Every version is kept in Plan history. Click any entry there to make it the active plan and it will appear in the Current plan panel.',
q: 'Is this medical advice?',
a: 'No. It is AI-generated guidance based on your inputs and logs. If you have a medical condition, are pregnant, or have a history of disordered eating, use a clinician or dietitian.',
},
];
const knownHeadings = new Set([
'plan', 'personalized weight-loss plan', 'your details', 'daily calorie range',
'protein target', 'healthy habits', 'weekly review steps', 'safety notes',
'getting started', 'targets', 'habits',
'plan', 'personalized weight-loss plan', 'your details', 'daily calorie target',
'daily calorie range', 'maintenance calories', 'protein target', 'meal logging strategy',
'activity adjustment rules', 'challenge-specific tactics', 'weekly review steps',
'safety notes', 'healthy habits', 'getting started', 'targets', 'habits',
]);
$: selectedPlan = plans.find(plan => plan.id === selectedPlanId) || plans[0];
$: caloriePlan = calculateCaloriePlan(settings);
$: targetCalories = Number(settings.calorieTarget) || caloriePlan?.goalCalories || 0;
$: profileMissing = [
['sex', settings.sex],
['age', settings.age],
['height', settings.heightCm],
['current weight', settings.weightKg],
].filter(([, value]) => !value).map(([label]) => label);
$: profileReady = profileMissing.length === 0;
$: planSections = sections(selectedPlan?.content);
$: summaryCards = [
{ label: 'Daily target', value: targetCalories ? `${targetCalories} kcal` : 'Add profile', note: settings.goal || 'Goal not set' },
{ label: 'Maintenance', value: caloriePlan ? `${caloriePlan.maintenanceCalories} kcal` : 'Unknown', note: caloriePlan ? `BMR ${caloriePlan.bmr} kcal` : 'Needs basics' },
{ label: 'Pace', value: settings.pace || 'Not set', note: settings.activityLevel ? `${settings.activityLevel} activity` : 'Activity not set' },
];
function cleanLine(line) {
return line.replace(/^#{1,6}\s*/, '').replace(/^[-*]\s+/, '').replace(/\*\*/g, '').replace(/^---+$/, '').trim();
return line
.replace(/^#{1,6}\s*/, '')
.replace(/^[-*]\s+/, '')
.replace(/^\d+\.\s+/, '')
.replace(/\*\*/g, '')
.replace(/^---+$/, '')
.trim();
}
function isHeading(line) {
@ -64,103 +92,213 @@
for (const line of lines) {
if (isHeading(line)) {
if (current.items.length) output.push(current);
current = { title: line.replace(/^\d+\.\s+/, '').replace(/:$/, ''), items: [] };
current = { title: line.replace(/:$/, ''), items: [] };
} else {
current.items.push(line);
}
}
output.push(current);
return output.filter(section => section.items.length || section.title !== 'Plan');
if (current.items.length) output.push(current);
return output;
}
function formatDate(value) {
if (!value) return 'Unknown date';
return new Date(value).toLocaleString([], { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' });
}
</script>
<section class="space-y-4">
<p class="text-sm text-slate-500">Your personal weight-loss plan, updated as you log meals and progress. Powered by AI, built from your profile.</p>
<div class="grid gap-4 xl:grid-cols-[0.8fr_1.2fr]">
<section class="card">
<h3 class="card-title">Profile</h3>
<div class="grid gap-3 p-4 md:grid-cols-2 xl:grid-cols-1">
<Field label="Height (cm)"><input class="input" type="number" min="80" bind:value={settings.heightCm}></Field>
<Field label="Current weight (kg)"><input class="input" type="number" min="20" step="0.1" bind:value={settings.weightKg}></Field>
<Field label="Target weight (kg)"><input class="input" type="number" min="20" step="0.1" bind:value={settings.targetWeightKg}></Field>
<Field label="Activity"><select class="input" bind:value={settings.activityLevel}><option>Low</option><option>Moderate</option><option>High</option></select></Field>
<Field label="Pace"><select class="input" bind:value={settings.pace}><option>Gentle</option><option>Steady</option><option>Aggressive</option></select></Field>
{#if !settings.heightCm || !settings.weightKg}
<p class="text-xs text-amber-600 font-medium">Fill in height and current weight to generate a plan.</p>
{/if}
<button class="btn-primary" type="button" on:click={generatePlan} disabled={planLoading || !settings.heightCm || !settings.weightKg}>{planLoading ? 'Generating...' : 'Generate new plan'}</button>
{#if planStatus}<p class:text-red-600={planError} class="text-sm font-semibold text-green-700">{planStatus}</p>{/if}
<section class="space-y-5">
<section class="overflow-hidden rounded-3xl border border-slate-200 bg-slate-950 text-white shadow-sm">
<div class="grid gap-6 p-5 lg:grid-cols-[1fr_420px] lg:p-7">
<div>
<div class="text-xs font-black uppercase tracking-[0.28em] text-blue-200">Personal plan</div>
<h2 class="mt-3 max-w-3xl text-3xl font-black tracking-tight sm:text-4xl">Build a plan you can actually follow this week.</h2>
<p class="mt-3 max-w-2xl text-sm leading-6 text-slate-300">Set your basics, add the behavior details that usually make dieting hard, then keep each generated version as a clean document.</p>
<div class="mt-5 flex flex-wrap gap-2 text-xs font-bold">
<span class="rounded-full bg-white/10 px-3 py-1 text-white">{plans.length || 0} saved version{plans.length === 1 ? '' : 's'}</span>
<span class="rounded-full bg-white/10 px-3 py-1 text-white">{profileReady ? 'Ready to generate' : `Missing ${profileMissing.join(', ')}`}</span>
{#if selectedPlan}<span class="rounded-full bg-blue-500 px-3 py-1 text-white">Active v{selectedPlan.version || 1}</span>{/if}
</div>
</div>
</section>
<section class="card">
<h3 class="card-title">Current plan</h3>
<div class="grid gap-4 p-4">
{#if selectedPlan}
<div class="flex flex-col justify-between gap-2 sm:flex-row sm:items-center">
<div>
<strong>{selectedPlan.title}</strong>
<div class="text-xs text-slate-500">Version {selectedPlan.version || 1} · {new Date(selectedPlan.createdAt).toLocaleString()}</div>
</div>
<button class="rounded-md bg-red-50 px-3 py-1.5 text-xs font-bold text-red-600 hover:bg-red-100" type="button" on:click={() => deletePlan(selectedPlan.id)}>Delete</button>
</div>
<div class="grid gap-3">
{#each sections(selectedPlan.content) as section}
<article class="rounded-xl border border-slate-200 bg-slate-50 p-4">
<h4 class="border-b border-slate-200 pb-2 text-base font-black text-slate-950">{section.title}</h4>
<ul class="mt-3 grid gap-2 text-sm leading-6 text-slate-700">
{#each section.items as item}<li>{item}</li>{/each}
</ul>
</article>
{/each}
</div>
<div class="border-t border-slate-200 pt-4">
<Field label="Fine-tune with meals, activities, or progress notes">
<textarea class="input min-h-28" bind:value={tuneNote} placeholder="Example: walked 7,000 steps daily this week, felt hungry at night, lost 0.3 kg"></textarea>
</Field>
<p class="mt-1 text-xs text-slate-400">These notes are sent to the AI to update your plan and are not stored afterwards.</p>
<button class="btn-primary mt-3" type="button" on:click={() => tunePlan(selectedPlan.id)} disabled={planLoading || !tuneNote?.trim()}>{planLoading ? 'Updating...' : 'Update plan with AI'}</button>
</div>
{:else}
<div class="empty-state">No plans yet. Fill in your profile and click Generate.</div>
{/if}
<div class="grid gap-3 sm:grid-cols-3 lg:grid-cols-1">
{#each summaryCards as item}
<article class="rounded-2xl border border-white/10 bg-white/10 p-4 backdrop-blur">
<div class="text-[0.68rem] font-black uppercase tracking-widest text-blue-100">{item.label}</div>
<div class="mt-1 text-2xl font-black text-white">{item.value}</div>
<div class="mt-1 text-xs text-slate-300">{item.note}</div>
</article>
{/each}
</div>
</section>
</div>
{#if plans.length > 1}
<section class="card">
<h3 class="card-title">Plan history</h3>
<div class="grid gap-2 p-4 md:grid-cols-2 xl:grid-cols-3">
{#each plans as plan}
<button class="rounded-xl border px-3 py-2 text-left text-sm hover:border-blue-300 hover:bg-blue-50" class:border-blue-500={plan.id === selectedPlanId} type="button" on:click={() => selectPlan(plan.id)}>
<strong>{plan.title}</strong>
<div class="text-xs text-slate-500">Version {plan.version || 1} · {new Date(plan.createdAt).toLocaleString()}</div>
</button>
{/each}
</div>
</section>
{/if}
<section class="card">
<h3 class="card-title">Frequently asked questions</h3>
<div class="divide-y divide-slate-100">
{#each faqs as faq, i}
<div>
<button
class="flex w-full items-center justify-between gap-4 px-4 py-3 text-left text-sm font-semibold text-slate-800 hover:bg-slate-50"
type="button"
on:click={() => faqOpen = faqOpen === i ? null : i}
>
<span>{faq.q}</span>
<svg class="h-4 w-4 flex-none text-slate-400 transition-transform {faqOpen === i ? 'rotate-180' : ''}" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" clip-rule="evenodd"/></svg>
</button>
{#if faqOpen === i}
<p class="px-4 pb-4 text-sm leading-6 text-slate-600">{faq.a}</p>
<div class="grid gap-5 xl:grid-cols-[370px_minmax(0,1fr)]">
<aside class="space-y-5">
<section class="card">
<div class="border-b border-slate-200 bg-white p-4">
<div class="flex items-center justify-between gap-3">
<h3 class="text-sm font-black text-slate-950">1. Body target</h3>
<span class="rounded-full px-2.5 py-1 text-xs font-black {profileReady ? 'bg-green-50 text-green-700' : 'bg-amber-50 text-amber-700'}">{profileReady ? 'Complete' : 'Needs basics'}</span>
</div>
<p class="mt-1 text-xs leading-5 text-slate-500">These numbers calculate the baseline. Save or generate to persist changes.</p>
</div>
<div class="grid gap-3 p-4 sm:grid-cols-2 xl:grid-cols-1">
<Field label="Sex"><select class="input" bind:value={settings.sex}><option value="">Select</option>{#each sexOptions as option}<option>{option}</option>{/each}</select></Field>
<Field label="Age"><input class="input" type="number" min="16" max="100" bind:value={settings.age}></Field>
<Field label="Height (cm)"><input class="input" type="number" min="80" bind:value={settings.heightCm}></Field>
<Field label="Current weight (kg)"><input class="input" type="number" min="20" step="0.1" bind:value={settings.weightKg}></Field>
<Field label="Target weight (kg)"><input class="input" type="number" min="20" step="0.1" bind:value={settings.targetWeightKg}></Field>
<Field label="Main goal"><select class="input" bind:value={settings.goal}>{#each goalOptions as option}<option>{option}</option>{/each}</select></Field>
<Field label="Activity"><select class="input" bind:value={settings.activityLevel}><option>Low</option><option>Moderate</option><option>High</option></select></Field>
<Field label="Pace"><select class="input" bind:value={settings.pace}><option>Gentle</option><option>Steady</option><option>Aggressive</option></select></Field>
<Field label="Daily kcal goal"><input class="input" type="number" min="0" bind:value={settings.calorieTarget} placeholder={caloriePlan ? `${caloriePlan.goalCalories}` : 'Calculated after profile'}></Field>
{#if caloriePlan}
<div class="rounded-2xl border border-blue-100 bg-blue-50 p-3 text-sm text-blue-950 sm:col-span-2 xl:col-span-1">
<div class="font-black">Calculated target</div>
<div>{planSummary(settings)}</div>
<div class="mt-1 text-xs text-blue-700">Maintenance {caloriePlan.maintenanceCalories} kcal/day · BMR {caloriePlan.bmr} kcal/day</div>
</div>
{:else}
<p class="text-xs font-medium text-amber-600 sm:col-span-2 xl:col-span-1">Fill in sex, age, height, and current weight before generating.</p>
{/if}
</div>
{/each}
</section>
<section class="card">
<div class="border-b border-slate-200 bg-white p-4">
<h3 class="text-sm font-black text-slate-950">2. Personal strategy</h3>
<p class="mt-1 text-xs leading-5 text-slate-500">This keeps the plan from reading like generic diet advice.</p>
</div>
<div class="grid gap-3 p-4">
<Field label="Why this matters"><select class="input" bind:value={settings.motivation}><option value="">Select</option>{#each motivationOptions as option}<option>{option}</option>{/each}</select></Field>
<Field label="Biggest challenge"><select class="input" bind:value={settings.challenge}><option value="">Select</option>{#each challengeOptions as option}<option>{option}</option>{/each}</select></Field>
<Field label="Tracking style"><select class="input" bind:value={settings.trackingStyle}><option value="">Select</option>{#each trackingOptions as option}<option>{option}</option>{/each}</select></Field>
<Field label="Calorie-counting experience"><select class="input" bind:value={settings.calorieCountingExperience}><option value="">Select</option>{#each calorieExperienceOptions as option}<option>{option}</option>{/each}</select></Field>
<Field label="Fasting interest"><select class="input" bind:value={settings.fastingInterest}><option value="">Select</option>{#each fastingOptions as option}<option>{option}</option>{/each}</select></Field>
</div>
</section>
<section class="card">
<div class="border-b border-slate-200 bg-white p-4">
<h3 class="text-sm font-black text-slate-950">3. Generate</h3>
<p class="mt-1 text-xs leading-5 text-slate-500">Creates a new version and keeps the older one.</p>
</div>
<div class="space-y-3 p-4">
<button class="btn-primary w-full" type="button" on:click={generatePlan} disabled={planLoading || !caloriePlan}>{planLoading ? 'Generating...' : 'Generate new plan'}</button>
{#if planStatus}<p class:text-red-600={planError} class="rounded-xl bg-slate-50 p-3 text-sm font-semibold text-green-700">{planStatus}</p>{/if}
</div>
</section>
<section class="card">
<div class="border-b border-slate-200 bg-white p-4">
<h3 class="text-sm font-black text-slate-950">Plan history</h3>
<p class="mt-1 text-xs leading-5 text-slate-500">Switch versions without deleting the current one.</p>
</div>
<div class="grid gap-2 p-4">
{#each plans as plan}
<button class="rounded-2xl border px-3 py-3 text-left text-sm hover:border-blue-300 hover:bg-blue-50" class:border-blue-500={plan.id === selectedPlanId} class:bg-blue-50={plan.id === selectedPlanId} type="button" on:click={() => selectPlan(plan.id)}>
<div class="flex items-center justify-between gap-3">
<strong class="text-slate-900">{plan.title}</strong>
{#if plan.id === selectedPlanId}<span class="rounded-full bg-blue-600 px-2 py-0.5 text-[0.65rem] font-black uppercase tracking-wider text-white">Active</span>{/if}
</div>
<div class="mt-1 text-xs text-slate-500">Version {plan.version || 1} · {formatDate(plan.createdAt)}</div>
</button>
{:else}
<div class="rounded-2xl border border-dashed border-slate-300 p-4 text-sm text-slate-500">No versions yet.</div>
{/each}
</div>
</section>
</aside>
<div class="space-y-5">
{#if selectedPlan}
<section class="overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-sm">
<div class="border-b border-slate-200 bg-gradient-to-r from-slate-50 to-blue-50 p-5">
<div class="flex flex-col justify-between gap-4 sm:flex-row sm:items-start">
<div>
<div class="text-xs font-black uppercase tracking-[0.22em] text-blue-700">Current plan</div>
<h3 class="mt-1 text-2xl font-black tracking-tight text-slate-950">{selectedPlan.title}</h3>
<p class="mt-1 text-sm text-slate-500">Version {selectedPlan.version || 1} · saved {formatDate(selectedPlan.createdAt)}</p>
</div>
<button class="rounded-xl bg-red-50 px-4 py-2 text-sm font-black text-red-600 hover:bg-red-100" type="button" on:click={() => deletePlan(selectedPlan.id)}>Delete plan</button>
</div>
<div class="mt-4 grid gap-3 md:grid-cols-3">
{#each summaryCards as item}
<div class="rounded-2xl border border-white bg-white/80 p-3 shadow-sm">
<div class="text-[0.68rem] font-black uppercase tracking-widest text-slate-400">{item.label}</div>
<div class="mt-1 text-lg font-black text-slate-950">{item.value}</div>
<div class="text-xs text-slate-500">{item.note}</div>
</div>
{/each}
</div>
</div>
<div class="grid gap-4 p-4 lg:p-5">
{#each planSections as section, index}
<article class="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm lg:p-5">
<div class="flex items-center gap-3 border-b border-slate-100 pb-3">
<span class="grid h-8 w-8 shrink-0 place-items-center rounded-full bg-blue-600 text-xs font-black text-white">{index + 1}</span>
<h4 class="text-base font-black text-slate-950">{section.title}</h4>
</div>
<ul class="mt-4 grid gap-3 text-sm leading-6 text-slate-700">
{#each section.items as item}
<li class="flex gap-3">
<span class="mt-2 h-2 w-2 shrink-0 rounded-full bg-blue-500"></span>
<span>{item}</span>
</li>
{/each}
</ul>
</article>
{:else}
<pre class="whitespace-pre-wrap rounded-2xl border border-slate-200 bg-slate-50 p-4 text-sm leading-6 text-slate-700">{selectedPlan.content}</pre>
{/each}
</div>
</section>
<section class="card">
<div class="border-b border-slate-200 bg-white p-4">
<h3 class="text-sm font-black text-slate-950">Adjust this plan</h3>
<p class="mt-1 text-xs leading-5 text-slate-500">Tell the AI what happened since the last version. The note is not saved, only the new plan is.</p>
</div>
<div class="p-4">
<Field label="Fine-tune with meals, activities, or progress notes">
<textarea class="input min-h-32" bind:value={tuneNote} placeholder="Example: walked 7,000 steps daily, felt hungry at night, lost 0.3 kg, struggled with breakfast"></textarea>
</Field>
<div class="mt-3 flex flex-col gap-3 sm:flex-row sm:items-center">
<button class="btn-primary" type="button" on:click={() => tunePlan(selectedPlan.id)} disabled={planLoading || !tuneNote?.trim()}>{planLoading ? 'Updating...' : 'Update plan with AI'}</button>
<p class="text-xs leading-5 text-slate-400">This keeps the current version in history and creates a new active version.</p>
</div>
</div>
</section>
{:else}
<section class="rounded-3xl border border-dashed border-slate-300 bg-white p-8 text-center shadow-sm">
<div class="mx-auto grid h-14 w-14 place-items-center rounded-2xl bg-blue-50 text-xl font-black text-blue-600">1</div>
<h3 class="mt-4 text-xl font-black text-slate-950">No plan yet</h3>
<p class="mx-auto mt-2 max-w-lg text-sm leading-6 text-slate-500">Fill in the body target and strategy sections, then generate your first saved plan.</p>
</section>
{/if}
<section class="card">
<h3 class="card-title">Plan questions</h3>
<div class="divide-y divide-slate-100">
{#each faqs as faq, i}
<div>
<button
class="flex w-full items-center justify-between gap-4 px-4 py-3 text-left text-sm font-semibold text-slate-800 hover:bg-slate-50"
type="button"
on:click={() => faqOpen = faqOpen === i ? null : i}
>
<span>{faq.q}</span>
<svg class="h-4 w-4 flex-none text-slate-400 transition-transform {faqOpen === i ? 'rotate-180' : ''}" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" clip-rule="evenodd"/></svg>
</button>
{#if faqOpen === i}
<p class="px-4 pb-4 text-sm leading-6 text-slate-600">{faq.a}</p>
{/if}
</div>
{/each}
</div>
</section>
</div>
</section>
</div>
</section>

View file

@ -8,10 +8,14 @@
export let modelStatus;
export let modelError;
export let modelSearching;
export let modelActionId;
export let accountEmail;
export let accountVerified;
export let accountStatus;
export let accountError;
export let healthConnectStatus;
export let healthConnectError;
export let healthConnectSyncing;
export let passwordStatus;
export let passwordError;
export let resetPasswordValue;
@ -25,6 +29,7 @@
export let removeModel;
export let saveAccountEmail;
export let sendVerificationEmail;
export let syncHealthConnect;
export let changePassword;
export let resetPassword;
</script>
@ -65,7 +70,7 @@
<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="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>
@ -73,10 +78,28 @@
<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="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>
</section>
<section class="card xl:col-span-2">
<div class="border-b border-slate-200 bg-gradient-to-r from-blue-50 to-slate-50 p-4">
<div class="text-xs font-black uppercase tracking-[0.24em] text-blue-700">Garmin and Health Connect</div>
<h3 class="mt-1 text-xl font-black text-slate-950">Connect Garmin from the Android app</h3>
<p class="mt-1 text-sm leading-6 text-slate-600">The web app cannot pair directly with a Garmin watch. Garmin writes to Android Health Connect, then Calorie AI imports that activity.</p>
</div>
<div class="grid gap-3 p-4 text-sm leading-6 text-slate-600 lg:grid-cols-3">
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm"><strong class="block text-slate-900">1. Garmin Connect</strong>Pair the watch and confirm it syncs in the Garmin Connect Android app.</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>
<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>
</section>
</div>
</section>

37
web/src/appConfig.js Normal file
View file

@ -0,0 +1,37 @@
export const pages = [
{ id: 'dashboard', label: 'Dashboard', icon: 'dashboard', group: 'Tracker' },
{ id: 'log', label: 'Log meal', icon: 'plus', group: 'Tracker' },
{ id: 'analytics', label: 'Analytics', icon: 'analytics', group: 'Tracker' },
{ id: 'diary', label: 'Diary', icon: 'diary', group: 'Tracker' },
{ id: 'trash', label: 'Trash', icon: 'trash', group: 'Tracker' },
{ id: 'plans', label: 'Plans', icon: 'plans', group: 'Tracker' },
{ id: 'settings', label: 'Settings', icon: 'settings', group: 'Admin' },
];
export const mealTypes = ['Breakfast', 'Lunch', 'Dinner', 'Snack', 'Other'];
export const fallbackModels = [
{ id: 'claude-haiku-4.5', name: 'Claude Haiku 4.5' },
{ id: 'claude-sonnet-4.6', name: 'Claude Sonnet 4.6' },
{ id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash' },
{ id: 'gpt-4o-mini', name: 'GPT-4o mini' },
];
export const defaultSettings = {
visionModel: fallbackModels[0].id,
taskModel: fallbackModels[0].id,
sex: '',
age: '',
heightCm: '',
weightKg: '',
targetWeightKg: '',
goal: 'Lose weight',
calorieTarget: '',
activityLevel: 'Moderate',
pace: 'Steady',
motivation: '',
challenge: '',
trackingStyle: '',
calorieCountingExperience: '',
fastingInterest: '',
};

66
web/src/calorie.js Normal file
View file

@ -0,0 +1,66 @@
function asNumber(value) {
const number = Number(value);
return Number.isFinite(number) && number > 0 ? number : 0;
}
function roundTo(value, step = 10) {
return Math.round(value / step) * step;
}
function activityFactor(level) {
return {
Low: 1.2,
Moderate: 1.55,
High: 1.725,
}[level] || 1.375;
}
function paceDelta(pace, goal) {
if (goal === 'Gain weight' || goal === 'Build muscle') return pace === 'Aggressive' ? 500 : pace === 'Steady' ? 350 : 250;
if (goal === 'Maintain weight') return 0;
return pace === 'Aggressive' ? -750 : pace === 'Steady' ? -500 : -250;
}
function weeklyChangeKg(pace, goal) {
if (goal === 'Maintain weight') return 0;
const amount = pace === 'Aggressive' ? 0.75 : pace === 'Steady' ? 0.5 : 0.25;
return goal === 'Gain weight' || goal === 'Build muscle' ? amount : -amount;
}
export function calculateCaloriePlan(settings) {
const weight = asNumber(settings.weightKg);
const height = asNumber(settings.heightCm);
const age = asNumber(settings.age);
if (!weight || !height || !age || !settings.sex) return null;
const sexOffset = settings.sex === 'Male' ? 5 : -161;
const bmr = 10 * weight + 6.25 * height - 5 * age + sexOffset;
const maintenanceCalories = roundTo(bmr * activityFactor(settings.activityLevel));
const goal = settings.goal || 'Lose weight';
const goalCalories = Math.max(settings.sex === 'Male' ? 1500 : 1200, roundTo(maintenanceCalories + paceDelta(settings.pace, goal)));
const proteinTarget = roundTo(weight * (goal === 'Build muscle' ? 1.8 : goal === 'Maintain weight' ? 1.3 : 1.6), 5);
const weeklyKg = weeklyChangeKg(settings.pace, goal);
const targetWeight = asNumber(settings.targetWeightKg);
const remainingKg = targetWeight ? targetWeight - weight : 0;
const weeksToTarget = remainingKg && weeklyKg && Math.sign(remainingKg) === Math.sign(weeklyKg)
? Math.ceil(Math.abs(remainingKg / weeklyKg))
: 0;
return {
bmr: roundTo(bmr),
maintenanceCalories,
goalCalories,
proteinTarget,
weeklyKg,
weeksToTarget,
};
}
export function planSummary(settings) {
const plan = calculateCaloriePlan(settings);
if (!plan) return 'Add sex, age, height, weight, and activity level to calculate a daily target.';
const direction = plan.weeklyKg > 0 ? 'gain' : plan.weeklyKg < 0 ? 'lose' : 'maintain';
const pace = plan.weeklyKg ? `${direction} about ${Math.abs(plan.weeklyKg).toFixed(2)} kg/week` : 'maintain weight';
const weeks = plan.weeksToTarget ? ` Estimated time to target: ${plan.weeksToTarget} weeks.` : '';
return `${plan.goalCalories} kcal/day, ${plan.proteinTarget} g protein/day, ${pace}.${weeks}`;
}

View file

@ -6,15 +6,17 @@ const png = Buffer.from(
);
async function mockModels(page) {
let models = [
{ id: 'claude-haiku-4.5', name: 'Claude Haiku 4.5' },
];
await page.route('**/api/models', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
defaultModel: 'claude-haiku-4.5',
models: [
{ id: 'claude-haiku-4.5', name: 'Claude Haiku 4.5' },
],
models,
}),
});
});
@ -35,19 +37,28 @@ async function mockModels(page) {
await page.route('**/api/models/add', async (route) => {
const body = route.request().postDataJSON();
models = [...models.filter(model => model.id !== body.id), { id: body.id, name: body.name || body.id }];
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
ok: true,
defaultModel: 'claude-haiku-4.5',
models: [
{ id: 'claude-haiku-4.5', name: 'Claude Haiku 4.5' },
{ id: body.id, name: body.name || body.id },
],
models,
}),
});
});
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) {
@ -107,6 +118,18 @@ async function mockEntries(page) {
});
}
async function mockActivities(page) {
let activities = [];
await page.route('**/api/activities', async (route) => {
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ activities }) });
});
await page.route('**/api/activities/sync', async (route) => {
const body = route.request().postDataJSON();
activities = [...(body.activities || []), ...activities];
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ activities }) });
});
}
async function mockSettings(page) {
let settings = {};
await page.route('**/api/settings', async (route) => {
@ -122,6 +145,7 @@ async function login(page) {
await mockModels(page);
await mockPlans(page);
await mockEntries(page);
await mockActivities(page);
await mockSettings(page);
await page.goto('/');
await expect(page).toHaveURL(/\/login$/);
@ -174,9 +198,15 @@ test.describe('authenticated app', () => {
await page.locator('nav').getByRole('button').filter({ hasText: 'Plans' }).click();
await expect(page.getByRole('heading', { name: 'Plans' })).toBeVisible();
await page.getByLabel('Sex').selectOption('Male');
await page.getByLabel('Age').fill('36');
await page.getByLabel('Height (cm)').fill('178');
await page.getByLabel('Current weight (kg)').fill('91');
await page.getByLabel('Target weight (kg)').fill('82');
await page.getByLabel('Why this matters').selectOption('Improve my overall health');
await page.getByLabel('Biggest challenge').selectOption('Being too busy');
await page.getByLabel('Tracking style').selectOption('Photo log meals');
await expect(page.getByText('Calculated target')).toBeVisible();
await page.getByRole('button', { name: 'Generate new plan' }).click();
await expect(page.getByText('Plan generated.')).toBeVisible();
await expect(page.getByText('Targets')).toBeVisible();
@ -235,11 +265,14 @@ test.describe('authenticated app', () => {
await page.locator('#modelSearch').fill('gemini');
await page.getByRole('button', { name: 'Search' }).click();
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('#taskModel').selectOption('gemini-2.5-flash');
await page.getByRole('button', { name: 'Save selected models' }).click();
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.locator('#mealDate').fill('2026-05-18');