Compare commits

..

No commits in common. "main" and "v1.3" have entirely different histories.
main ... v1.3

90 changed files with 618 additions and 4410 deletions

View file

@ -12,6 +12,8 @@ on:
jobs:
release-apk:
runs-on: android-ci
env:
GRADLE_VERSION: 8.10.2
steps:
- name: Check out repository
run: |
@ -21,28 +23,16 @@ jobs:
- name: Install tools
run: |
apt-get update
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
apt-get install -y --no-install-recommends ca-certificates curl nodejs unzip
curl -fsSL "https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip" -o /tmp/gradle.zip
unzip -q /tmp/gradle.zip -d /opt
ln -s "/opt/gradle-${GRADLE_VERSION}/bin/gradle" /usr/local/bin/gradle
- name: Build web app and sync Capacitor
env:
APP_SERVER_URL: ${{ secrets.APP_SERVER_URL }}
- name: Build installable APK
run: |
cd web
npm ci
npm run build
npx cap sync android
- name: Build APK
run: |
cd web/android
chmod +x gradlew
./gradlew --no-daemon assembleDebug
mkdir -p ../../dist
cp app/build/outputs/apk/debug/*.apk "../../dist/calorie-ai-${GITHUB_REF_NAME}.apk"
gradle --no-daemon :app:testDebugUnitTest :app:assembleDebug
mkdir -p dist
cp app/build/outputs/apk/debug/*.apk "dist/calorie-ai-${GITHUB_REF_NAME}.apk"
- name: Publish Forgejo release asset
env:
@ -54,10 +44,10 @@ jobs:
auth_header="Authorization: token ${TOKEN}"
release_json="$(curl -fsS -H "$auth_header" "$FORGEJO_API/repos/${GITHUB_REPOSITORY}/releases/tags/${GITHUB_REF_NAME}" || true)"
if [ -z "$release_json" ]; then
release_json="$(node -e 'console.log(JSON.stringify({ tag_name: process.env.GITHUB_REF_NAME, name: `Calorie AI ${process.env.GITHUB_REF_NAME}`, body: "Capacitor Android APK for Calorie AI.", draft: false, prerelease: false }))' | curl -fsS -X POST -H "$auth_header" -H 'Content-Type: application/json' --data-binary @- "$FORGEJO_API/repos/${GITHUB_REPOSITORY}/releases")"
release_json="$(node -e 'console.log(JSON.stringify({ tag_name: process.env.GITHUB_REF_NAME, name: `Calorie AI ${process.env.GITHUB_REF_NAME}`, body: "Installable Android debug 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 expected = "calorie-ai-" + process.env.GITHUB_REF_NAME + ".apk"; const asset = (release.assets || []).find(item => item.name === expected); 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 asset = (release.assets || []).find(item => item.name === `calorie-ai-${process.env.GITHUB_REF_NAME}.apk`); 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,14 +19,7 @@ jobs:
- name: Install Node and browsers
run: |
apt-get update
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"
apt-get install -y --no-install-recommends ca-certificates curl nodejs npm
cd web
npm install
npx playwright install --with-deps chromium
@ -38,5 +31,4 @@ 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,6 +8,5 @@ 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 meal entries, settings, plans, and synced activity in PostgreSQL.
- Imports Garmin Connect and gym app activity through Android Health Connect.
- 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`.
- 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,16 +48,6 @@ 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.
@ -75,9 +65,7 @@ Then open:
http://127.0.0.1:8095
```
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.
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.
## AI Endpoint
@ -92,4 +80,6 @@ Example base URLs:
- `https://api.openai.com/v1`
- An emulator host-loopback URL ending in `:11434/v1` for a local OpenAI-compatible service
The web and Android clients use the same authenticated server APIs for entries, settings, plans, models, and synced activity.
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.

View file

@ -12,8 +12,8 @@ android {
applicationId 'com.danvics.calorieai'
minSdk 26
targetSdk 35
versionCode 11
versionName '1.11'
versionCode 4
versionName '1.3'
}
buildFeatures {
@ -47,7 +47,6 @@ 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,9 +1,6 @@
<?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,15 +8,12 @@ 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
@ -44,7 +41,6 @@ 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 {
@ -64,33 +60,8 @@ 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) {
@ -127,33 +98,8 @@ 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) {
@ -233,8 +179,7 @@ private fun AppRoot(repo: ApiRepository) {
val visionEstimate = if (selectedImage != null && appState.settings.visionModel.isNotBlank()) {
repo.chat(buildVisionBody(appState.settings.visionModel, draft.description, draft.measure, selectedImage!!))
} else ""
val todayHistory = buildTodayHistory(appState.entries, draft.date.ifBlank { LocalDate.now().toString() })
val nutritionJson = repo.chat(buildNutritionBody(appState.settings.taskModel, draft.description, draft.measure, visionEstimate, todayHistory))
val nutritionJson = repo.chat(buildNutritionBody(appState.settings.taskModel, draft.description, draft.measure, visionEstimate))
val estimate = parser.parse(nutritionJson)
val meal = draft.copy(
id = editing?.id ?: UUID.randomUUID().toString(),
@ -297,22 +242,18 @@ private fun AppRoot(repo: ApiRepository) {
},
onGeneratePlan = {
val settings = appState.settings
val calculated = calculateCalorieTarget(settings)
if (calculated == null) {
planStatus = "Enter sex, age, height, and current weight in Settings first."
if (settings.heightCm.isBlank() || settings.weightKg.isBlank()) {
planStatus = "Enter 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 recentActivity = recentActivitySummary(appState.activities, 10)
val content = repo.chat(buildPlanBody(planSettings.taskModel, planSettings, recentMeals, recentActivity))
val content = repo.chat(buildPlanBody(settings.taskModel, settings, recentMeals))
val plan = Plan(
id = UUID.randomUUID().toString(),
title = "Plan ${appState.plans.plans.size + 1}",
@ -322,7 +263,7 @@ private fun AppRoot(repo: ApiRepository) {
)
repo.savePlan(plan)
}.onSuccess { plans ->
withContext(Dispatchers.Main) { appState = appState.copy(plans = plans, settings = planSettings); planStatus = "Plan generated."; planBusy = false }
withContext(Dispatchers.Main) { appState = appState.copy(plans = plans); planStatus = "Plan generated."; planBusy = false }
}.onFailure { e ->
withContext(Dispatchers.Main) {
if (e is UnauthorizedException) handleUnauth()
@ -341,8 +282,7 @@ 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 recentActivity = recentActivitySummary(appState.activities, 15)
val content = repo.chat(buildTuneBody(appState.settings.taskModel, currentPlan.content, note, recentMeals, recentActivity))
val content = repo.chat(buildTuneBody(appState.settings.taskModel, currentPlan.content, note, recentMeals))
val version = appState.plans.plans.maxOfOrNull { it.version }?.plus(1) ?: 2
val updated = Plan(
id = UUID.randomUUID().toString(),
@ -378,8 +318,6 @@ private fun AppRoot(repo: ApiRepository) {
.onFailure { e -> withContext(Dispatchers.Main) { if (e is UnauthorizedException) handleUnauth() } }
}
},
healthConnectStatus = healthConnectStatus,
onHealthConnectSync = { syncHealthConnect() },
onDisconnect = {
repo.disconnect()
connected = false
@ -406,68 +344,30 @@ private fun buildVisionBody(model: String, description: String, measure: String,
.put(JSONObject().put("type", "image_url").put("image_url", JSONObject().put("url", image.dataUri)))
)))
private fun buildTodayHistory(entries: List<MealEntry>, date: String): String {
val todayEntries = entries.filter { it.date == date && it.trashedAt.isBlank() }
if (todayEntries.isEmpty()) return "No meals logged yet today."
val rows = todayEntries.joinToString("\n") {
"- ${it.estimate.mealName}: ${it.estimate.calories} kcal, P${it.estimate.proteinGrams}g C${it.estimate.carbsGrams}g F${it.estimate.fatGrams}g"
}
val totals = todayEntries.fold(intArrayOf(0, 0, 0, 0)) { acc, e ->
acc[0] += e.estimate.calories; acc[1] += e.estimate.proteinGrams
acc[2] += e.estimate.carbsGrams; acc[3] += e.estimate.fatGrams; acc
}
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 =
private fun buildNutritionBody(model: String, description: String, measure: String, visionEstimate: String): JSONObject =
JSONObject().put("model", model).put("temperature", 0.1)
.put("messages", JSONArray()
.put(JSONObject().put("role", "system").put("content",
"You are a nutrition estimator. Output ONLY a raw JSON object — no markdown fences, no explanation, no text before or after.\n" +
"You MUST use these exact key names: mealName, calories, proteinGrams, carbsGrams, fatGrams, fruitServings, vegetableServings, foodGroups, notes.\n" +
"calories, proteinGrams, carbsGrams, fatGrams must be integers. fruitServings and vegetableServings must be numbers. foodGroups must be a short comma-separated string.\n" +
"Use the notes field for a brief personalised observation based on the user's diet history (e.g. macro balance, produce intake, daily total context).\n" +
"Output format: {\"mealName\":\"...\",\"calories\":0,\"proteinGrams\":0,\"carbsGrams\":0,\"fatGrams\":0,\"fruitServings\":0,\"vegetableServings\":0,\"foodGroups\":\"...\",\"notes\":\"...\"}"
"You are a nutrition estimator. Respond with ONLY a JSON object — no markdown, no explanation, no extra text.\n" +
"Required keys: mealName (string), calories (integer), proteinGrams (integer), carbsGrams (integer), fatGrams (integer), fruitServings (decimal), vegetableServings (decimal), foodGroups (string), notes (string).\n" +
"Example: {\"mealName\":\"Grilled chicken and rice\",\"calories\":520,\"proteinGrams\":45,\"carbsGrams\":48,\"fatGrams\":10,\"fruitServings\":0,\"vegetableServings\":1.5,\"foodGroups\":\"protein, grains\",\"notes\":\"standard portion\"}"
))
.put(JSONObject().put("role", "user").put("content",
"Estimate nutrition for this meal.\nDescription: $description\nPortion: $measure\nImage analysis: $visionEstimate\n\nUser's meals logged today:\n$todayHistory"
"Estimate the nutrition for this meal:\nDescription: $description\nPortion: $measure\nAdditional context: $visionEstimate"
)))
private fun buildPlanBody(model: String, settings: ServerSettings, recentMeals: String, recentActivity: String): JSONObject =
private fun buildPlanBody(model: String, settings: ServerSettings, recentMeals: String): JSONObject =
JSONObject().put("model", model).put("temperature", 0.2)
.put("messages", JSONArray()
.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", "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", "user").put("content",
"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."
"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"
)))
private fun buildTuneBody(model: String, currentContent: String, note: String, recentMeals: String, recentActivity: String): JSONObject =
private fun buildTuneBody(model: String, currentContent: String, note: String, recentMeals: 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\n\nRecent activity:\n$recentActivity"
"Current plan:\n$currentContent\n\nUpdate notes:\n$note\n\nRecent meals:\n$recentMeals"
)))

View file

@ -6,31 +6,58 @@ import org.json.JSONObject
class NutritionParser {
fun parse(content: String): NutritionEstimate {
// Strip markdown code fences and extract the JSON object
var cleaned = content.trim()
// Strip leading markdown fence line (e.g. "```json\n")
val fenceEnd = cleaned.indexOf('\n')
if (cleaned.startsWith("```") && fenceEnd >= 0) cleaned = cleaned.substring(fenceEnd + 1)
if (cleaned.endsWith("```")) cleaned = cleaned.dropLast(3)
cleaned = cleaned.trim()
// Extract the JSON object in case there's any surrounding prose
val start = cleaned.indexOf('{')
val end = cleaned.lastIndexOf('}')
if (start >= 0 && end > start) cleaned = cleaned.substring(start, end + 1)
val json = JSONObject(cleaned)
return NutritionEstimate(
mealName = json.optString("mealName", "Meal").ifBlank { "Meal" },
calories = json.optInt("calories").coerceAtLeast(0),
proteinGrams = json.optInt("proteinGrams").coerceAtLeast(0),
carbsGrams = json.optInt("carbsGrams").coerceAtLeast(0),
fatGrams = json.optInt("fatGrams").coerceAtLeast(0),
fruitServings = json.optDouble("fruitServings", 0.0).coerceAtLeast(0.0),
vegetableServings = json.optDouble("vegetableServings", 0.0).coerceAtLeast(0.0),
mealName = json.optString("mealName", "").ifBlank { "Meal" },
calories = safeInt(json, "calories", "kcal", "energy"),
proteinGrams = safeInt(json, "proteinGrams", "protein", "protein_g", "proteins"),
carbsGrams = safeInt(json, "carbsGrams", "carbs", "carbohydrates", "carbohydrateGrams", "carbs_g", "carbohydrate"),
fatGrams = safeInt(json, "fatGrams", "fat", "totalFat", "fat_g", "fats"),
fruitServings = safeDouble(json, "fruitServings", "fruit", "fruitsServings", "fruits"),
vegetableServings = safeDouble(json, "vegetableServings", "vegetables", "vegetablesServings", "veggies"),
foodGroups = stringifyGroups(json.opt("foodGroups")),
notes = json.optString("notes", ""),
raw = content
)
}
private fun safeInt(json: JSONObject, vararg keys: String): Int {
for (key in keys) {
val raw = json.opt(key) ?: continue
val n = when (raw) {
is Number -> raw.toInt()
is String -> raw.toDoubleOrNull()?.toInt() ?: continue
else -> continue
}
if (n > 0) return n
}
return 0
}
private fun safeDouble(json: JSONObject, vararg keys: String): Double {
for (key in keys) {
val raw = json.opt(key) ?: continue
val n = when (raw) {
is Number -> raw.toDouble()
is String -> raw.toDoubleOrNull() ?: continue
else -> continue
}
if (n > 0.0) return n
}
return 0.0
}
private fun stringifyGroups(value: Any?): String = when (value) {
null, JSONObject.NULL -> ""
is JSONArray -> (0 until value.length()).joinToString(", ") { value.optString(it) }

View file

@ -43,14 +43,12 @@ 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
@ -77,20 +75,12 @@ 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")))
@ -111,14 +101,6 @@ 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()
@ -157,9 +139,6 @@ 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"))
@ -168,20 +147,12 @@ 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" },
motivation = json.optString("motivation"),
challenge = json.optString("challenge"),
trackingStyle = json.optString("trackingStyle"),
calorieCountingExperience = json.optString("calorieCountingExperience"),
fastingInterest = json.optString("fastingInterest")
pace = json.optString("pace", "Steady").ifBlank { "Steady" }
)
private fun parsePlans(json: JSONObject): PlansState {
@ -242,30 +213,3 @@ 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

@ -1,66 +0,0 @@
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,20 +41,6 @@ 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()
}
@ -76,26 +62,17 @@ 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 motivation: String = "",
val challenge: String = "",
val trackingStyle: String = "",
val calorieCountingExperience: String = "",
val fastingInterest: String = ""
val pace: String = "Steady"
)
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

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

View file

@ -6,23 +6,18 @@ 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>, activities: List<ActivityRecord>, settings: ServerSettings, onLog: () -> Unit) = Page {
fun DashboardScreen(entries: List<MealEntry>, 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() ?: calculateCalorieTarget(settings)?.goalCalories ?: 0
val calorieTarget = settings.calorieTarget.toIntOrNull() ?: 0
Card(
modifier = Modifier.fillMaxWidth(),
@ -41,11 +36,6 @@ fun DashboardScreen(entries: List<MealEntry>, activities: List<ActivityRecord>,
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)
@ -86,17 +76,4 @@ fun DashboardScreen(entries: List<MealEntry>, activities: List<ActivityRecord>,
}
}
}
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,8 +9,6 @@ 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(
@ -24,7 +22,6 @@ 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) }
@ -47,21 +44,18 @@ fun PlansScreen(
SectionCard("Generate plan") {
Text(
"Your plan is built from your body stats, goal, habits, challenges, and recent meals.",
"Your plan is built from your profile and recent meals, then updated as you log and progress.",
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 && calorieTarget != null,
enabled = !planBusy && settings.heightCm.isNotBlank() && settings.weightKg.isNotBlank(),
onClick = onGenerate,
modifier = Modifier.fillMaxWidth()
) { Text(if (planBusy) "Generating..." else "Generate new plan") }
if (calorieTarget == null) {
if (settings.heightCm.isBlank() || settings.weightKg.isBlank()) {
Text(
"Fill in sex, age, height, and weight in Settings first.",
"Fill in your height and weight in Settings first.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)

View file

@ -5,28 +5,17 @@ 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) }
@ -40,10 +29,6 @@ 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")
@ -52,38 +37,12 @@ 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

@ -23,8 +23,9 @@ class NutritionParserTest {
assertEquals(1.5, estimate.vegetableServings, 0.001)
}
@Test fun parsesExactKeys() {
val estimate = parser.parse("{\"mealName\":\"Pasta\",\"calories\":580,\"proteinGrams\":28,\"carbsGrams\":80,\"fatGrams\":14,\"fruitServings\":0,\"vegetableServings\":1,\"foodGroups\":\"grain\",\"notes\":\"\"}")
@Test fun parsesShortKeyAliases() {
// Models often return "protein" instead of "proteinGrams" etc.
val estimate = parser.parse("{\"mealName\":\"Pasta\",\"calories\":580,\"protein\":28,\"carbs\":80,\"fat\":14}")
assertEquals("Pasta", estimate.mealName)
assertEquals(580, estimate.calories)
assertEquals(28, estimate.proteinGrams)
@ -46,7 +47,7 @@ class NutritionParserTest {
}
@Test fun handlesStringNumbers() {
val estimate = parser.parse("{\"mealName\":\"Oatmeal\",\"calories\":\"320\",\"proteinGrams\":\"12\",\"carbsGrams\":\"55\",\"fatGrams\":\"8\"}")
val estimate = parser.parse("{\"mealName\":\"Oatmeal\",\"calories\":\"320\",\"protein\":\"12\",\"carbs\":\"55\",\"fat\":\"8\"}")
assertEquals(320, estimate.calories)
assertEquals(12, estimate.proteinGrams)
}

View file

@ -4,7 +4,6 @@ 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

101
web/android/.gitignore vendored
View file

@ -1,101 +0,0 @@
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
# Built application files
*.apk
*.aar
*.ap_
*.aab
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
# Uncomment the following line in case you need and you don't have the release build type files in your app
# release/
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
captures/
# IntelliJ
*.iml
.idea/workspace.xml
.idea/tasks.xml
.idea/gradle.xml
.idea/assetWizardSettings.xml
.idea/dictionaries
.idea/libraries
# Android Studio 3 in .gitignore file.
.idea/caches
.idea/modules.xml
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
.idea/navEditor.xml
# Keystore files
# Uncomment the following lines if you do not want to check your keystore files in.
#*.jks
#*.keystore
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
.cxx/
# Google Services (e.g. APIs or Firebase)
# google-services.json
# Freeline
freeline.py
freeline/
freeline_project_description.json
# fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
fastlane/readme.md
# Version control
vcs.xml
# lint
lint/intermediates/
lint/generated/
lint/outputs/
lint/tmp/
# lint/reports/
# Android Profiling
*.hprof
# Cordova plugins for Capacitor
capacitor-cordova-android-plugins
# Copied web assets
app/src/main/assets/public
# Generated Config files
app/src/main/assets/capacitor.config.json
app/src/main/assets/capacitor.plugins.json
app/src/main/res/xml/config.xml

View file

@ -1,2 +0,0 @@
/build/*
!/build/.npmkeep

View file

@ -1,64 +0,0 @@
apply plugin: 'com.android.application'
apply plugin: 'org.jetbrains.kotlin.android'
android {
namespace = "com.danvics.calorieai"
compileSdk = rootProject.ext.compileSdkVersion
defaultConfig {
applicationId "com.danvics.calorieai"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
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.
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
kotlinOptions {
jvmTarget = '21'
}
}
repositories {
flatDir{
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
}
}
dependencies {
implementation fileTree(include: ['*.jar'], dir: 'libs')
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"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
implementation project(':capacitor-cordova-android-plugins')
}
apply from: 'capacitor.build.gradle'
try {
def servicesJSON = file('google-services.json')
if (servicesJSON.text) {
apply plugin: 'com.google.gms.google-services'
}
} catch(Exception e) {
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
}

View file

@ -1,19 +0,0 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
}
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
}
if (hasProperty('postBuildExtras')) {
postBuildExtras()
}

View file

@ -1,21 +0,0 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View file

@ -1,26 +0,0 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import android.content.Context;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import org.junit.Test;
import org.junit.runner.RunWith;
/**
* Instrumented test, which will execute on an Android device.
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
@RunWith(AndroidJUnit4.class)
public class ExampleInstrumentedTest {
@Test
public void useAppContext() throws Exception {
// Context of the app under test.
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
assertEquals("com.getcapacitor.app", appContext.getPackageName());
}
}

View file

@ -1,58 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
android:name=".MainActivity"
android:label="@string/title_activity_main"
android:theme="@style/AppTheme.NoActionBarLaunch"
android:launchMode="singleTask"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</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"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"></meta-data>
</provider>
</application>
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<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

@ -1,171 +0,0 @@
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,12 +0,0 @@
package com.danvics.calorieai;
import android.os.Bundle;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {
@Override
public void onCreate(Bundle savedInstanceState) {
registerPlugin(HealthConnectPlugin.class);
super.onCreate(savedInstanceState);
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

View file

@ -1,34 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillType="evenOdd"
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
android:strokeColor="#00000000"
android:strokeWidth="1">
<aapt:attr name="android:fillColor">
<gradient
android:endX="78.5885"
android:endY="90.9159"
android:startX="48.7653"
android:startY="61.0927"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
android:strokeColor="#00000000"
android:strokeWidth="1" />
</vector>

View file

@ -1,170 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillColor="#26A69A"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
</vector>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

View file

@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<WebView
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

View file

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

View file

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>

View file

@ -1,7 +0,0 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="app_name">Calorie AI</string>
<string name="title_activity_main">Calorie AI</string>
<string name="package_name">com.danvics.calorieai</string>
<string name="custom_url_scheme">com.danvics.calorieai</string>
</resources>

View file

@ -1,22 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<item name="android:background">@null</item>
</style>
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
<item name="android:background">@drawable/splash</item>
</style>
</resources>

View file

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="my_images" path="." />
<cache-path name="my_cache_images" path="." />
</paths>

View file

@ -1,18 +0,0 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import org.junit.Test;
/**
* Example local unit test, which will execute on the development machine (host).
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
public class ExampleUnitTest {
@Test
public void addition_isCorrect() throws Exception {
assertEquals(4, 2 + 2);
}
}

View file

@ -1,30 +0,0 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
repositories {
google()
mavenCentral()
}
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
}
}
apply from: "variables.gradle"
allprojects {
repositories {
google()
mavenCentral()
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}

View file

@ -1,3 +0,0 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')

View file

@ -1,22 +0,0 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx1536m
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true

Binary file not shown.

View file

@ -1,7 +0,0 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
web/android/gradlew vendored
View file

@ -1,251 +0,0 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

View file

@ -1,94 +0,0 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View file

@ -1,5 +0,0 @@
include ':app'
include ':capacitor-cordova-android-plugins'
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
apply from: 'capacitor.settings.gradle'

View file

@ -1,16 +0,0 @@
ext {
minSdkVersion = 26
compileSdkVersion = 36
targetSdkVersion = 36
androidxActivityVersion = '1.11.0'
androidxAppCompatVersion = '1.7.1'
androidxCoordinatorLayoutVersion = '1.3.0'
androidxCoreVersion = '1.17.0'
androidxFragmentVersion = '1.8.9'
coreSplashScreenVersion = '1.2.0'
androidxWebkitVersion = '1.14.0'
junitVersion = '4.13.2'
androidxJunitVersion = '1.3.0'
androidxEspressoCoreVersion = '3.7.0'
cordovaAndroidVersion = '14.0.1'
}

View file

@ -1,20 +0,0 @@
import { CapacitorConfig } from '@capacitor/cli';
const serverUrl = process.env.APP_SERVER_URL;
const config: CapacitorConfig = {
appId: 'com.danvics.calorieai',
appName: 'Calorie AI',
webDir: 'dist',
...(serverUrl ? {
server: {
url: serverUrl,
cleartext: true,
},
} : {}),
android: {
allowMixedContent: true,
},
};
export default config;

View file

@ -1,36 +1,21 @@
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:
CALORIE_AI_DATABASE_URL: postgresql://calorie_ai@postgres:5432/calorie_ai
LITELLM_API_BASE: https://llm.danvics.com/v1
LITELLM_API_BASE: http://litellm:4000/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

1192
web/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -9,21 +9,16 @@
"test": "playwright test"
},
"devDependencies": {
"@capacitor/android": "^8.3.4",
"@capacitor/cli": "^8.3.4",
"@capacitor/core": "^8.3.4",
"@playwright/test": "^1.56.1",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@tailwindcss/vite": "^4.3.0",
"@playwright/test": "^1.56.1",
"svelte": "^5.55.8",
"tailwindcss": "^4.3.0",
"typescript": "^6.0.3",
"vite": "^8.0.13"
},
"dependencies": {
"argon2": "^0.44.0",
"express": "^5.2.1",
"nodemailer": "^7.0.11",
"pg": "^8.21.0"
"nodemailer": "^7.0.11"
}
}

View file

@ -12,8 +12,6 @@ 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,7 +4,6 @@ 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');
@ -19,7 +18,6 @@ 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 || '',
@ -52,12 +50,23 @@ 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() {
const stored = await store.getAuthConfig();
let stored = {};
stored = readJsonFile(authFile, {});
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 || '';
@ -77,7 +86,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) {
await store.saveAuthConfig({
writePrivateJson(authFile, {
user,
email,
emailVerified: stored.emailVerified === true,
@ -87,15 +96,15 @@ async function loadAuthConfig() {
updatedAt: new Date().toISOString(),
...(plainPassword && !stored.passwordHash ? { initialPassword: plainPassword } : {}),
});
console.log(`Calorie AI auth is stored in ${store.databaseLabel}`);
console.log(`Calorie AI auth is stored at ${authFile}`);
}
return { ...stored, user, email, emailVerified: stored.emailVerified === true, passwordHash, sessionSecret };
}
async function saveAuthConfig(extra = {}) {
function saveAuthConfig(extra = {}) {
auth = { ...auth, ...extra, updatedAt: new Date().toISOString() };
await store.saveAuthConfig({
writePrivateJson(authFile, {
user: auth.user,
email: auth.email || '',
emailVerified: auth.emailVerified === true,
@ -201,29 +210,41 @@ async function sendMail(to, subject, text, html = '') {
await transporter.sendMail({ from: config.from, to, subject, text, html: html || undefined });
}
async function loadModelConfig() {
const stored = await store.getModelConfig();
function loadModelConfig() {
const stored = readJsonFile(modelsFile, {});
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 (!stored.models?.length) await saveModelConfig(config);
if (!fs.existsSync(modelsFile)) saveModelConfig(config);
return config;
}
async function saveModelConfig(config = modelConfig) {
await store.saveModelConfig({ ...config, updatedAt: new Date().toISOString() });
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 getPlans(req, res) {
send(res, 200, JSON.stringify(await store.getPlans()));
send(res, 200, JSON.stringify(loadPlanConfig()));
}
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 = await store.getPlans();
const config = loadPlanConfig();
const plan = {
id: payload.id || crypto.randomUUID(),
title: String(payload.title || `Weight-loss plan ${config.plans.length + 1}`),
@ -232,47 +253,91 @@ async function createPlan(req, res) {
previousPlanId: payload.previousPlanId || undefined,
createdAt: payload.createdAt || new Date().toISOString(),
};
send(res, 200, JSON.stringify(await store.savePlan(plan)));
config.plans = [plan, ...config.plans];
config.selectedPlanId = plan.id;
savePlanConfig(config);
send(res, 200, JSON.stringify(config));
}
async function selectPlanRoute(req, res) {
const payload = JSON.parse(await readBody(req) || '{}');
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 || '')));
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));
}
async function deletePlanRoute(req, res) {
const payload = JSON.parse(await readBody(req) || '{}');
send(res, 200, JSON.stringify(await store.deletePlan(payload.id)));
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() });
}
async function getSettings(req, res) {
send(res, 200, JSON.stringify(await store.getSettings()));
send(res, 200, JSON.stringify(loadSettings()));
}
async function updateSettings(req, res) {
try {
const payload = JSON.parse(await readBody(req) || '{}');
const current = await store.getSettings();
const allowed = Object.keys(defaultSettings);
const current = loadSettings();
const allowed = ['visionModel', 'taskModel', 'heightCm', 'weightKg', 'targetWeightKg', 'calorieTarget', 'activityLevel', 'pace'];
const updated = { ...current };
for (const key of allowed) if (payload[key] !== undefined) updated[key] = payload[key];
await store.saveSettings(updated);
saveSettingsData(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(await store.getEntries()));
send(res, 200, JSON.stringify(loadEntriesData()));
}
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' }));
send(res, 200, JSON.stringify(await store.upsertMealEntry(payload)));
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));
} catch (error) {
send(res, 400, JSON.stringify({ error: error.message }));
}
@ -282,7 +347,12 @@ 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));
send(res, 200, JSON.stringify(await store.moveEntriesToTrash([...ids])));
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));
} catch (error) {
send(res, 400, JSON.stringify({ error: error.message }));
}
@ -291,8 +361,13 @@ async function deleteEntriesRoute(req, res) {
async function restoreEntryRoute(req, res) {
try {
const payload = JSON.parse(await readBody(req) || '{}');
const data = await store.restoreEntry(payload.id);
if (!data) return send(res, 404, JSON.stringify({ error: 'Entry not found in trash' }));
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);
send(res, 200, JSON.stringify(data));
} catch (error) {
send(res, 400, JSON.stringify({ error: error.message }));
@ -301,21 +376,10 @@ async function restoreEntryRoute(req, res) {
async function clearTrashRoute(req, res) {
try {
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 || '')));
const data = loadEntriesData();
data.trash = [];
saveEntriesData(data);
send(res, 200, JSON.stringify(data));
} catch (error) {
send(res, 400, JSON.stringify({ error: error.message }));
}
@ -336,7 +400,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' 'unsafe-inline'; 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'; connect-src 'self'; base-uri 'self'; frame-ancestors 'none'",
...headers,
});
res.end(headOnly ? '' : body);
@ -497,7 +561,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;
await saveModelConfig();
saveModelConfig();
send(res, 200, JSON.stringify({ ok: true, models: modelConfig.models, defaultModel: modelConfig.defaultModel }));
} catch (error) {
send(res, 400, JSON.stringify({ error: error.message }));
@ -510,7 +574,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;
await saveModelConfig();
saveModelConfig();
send(res, 200, JSON.stringify({ ok: true, models: modelConfig.models, defaultModel: modelConfig.defaultModel }));
} catch (error) {
send(res, 400, JSON.stringify({ error: error.message }));
@ -540,7 +604,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);
await saveAuthConfig({ passwordHash: auth.passwordHash, resetTokenHash: undefined, resetExpiresAt: undefined });
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 }));
@ -551,7 +615,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');
await saveAuthConfig({ resetTokenHash: tokenHash(token), resetExpiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString() });
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');
@ -571,7 +635,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');
await saveAuthConfig({ resetTokenHash: tokenHash(token), resetExpiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString() });
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');
@ -589,7 +653,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);
await saveAuthConfig({ passwordHash, resetTokenHash: undefined, resetExpiresAt: undefined });
saveAuthConfig({ passwordHash, resetTokenHash: undefined, resetExpiresAt: undefined });
send(res, 200, JSON.stringify({ ok: true }));
} catch (error) {
send(res, 400, JSON.stringify({ error: error.message }));
@ -601,7 +665,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' }));
await saveAuthConfig({ email, emailVerified: email === auth.email ? auth.emailVerified : false });
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 }));
@ -612,7 +676,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');
await saveAuthConfig({ verificationTokenHash: tokenHash(token), verificationExpiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() });
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');
@ -628,7 +692,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' }));
await saveAuthConfig({ emailVerified: true, verificationTokenHash: undefined, verificationExpiresAt: undefined });
saveAuthConfig({ emailVerified: true, verificationTokenHash: undefined, verificationExpiresAt: undefined });
send(res, 200, JSON.stringify({ ok: true }));
} catch (error) {
send(res, 400, JSON.stringify({ error: error.message }));
@ -642,12 +706,8 @@ function logout(req, res) {
}
async function start() {
store = await createStore({
dataDir,
legacyFiles: { authFile, modelsFile, plansFile, settingsFile, entriesFile },
});
auth = await loadAuthConfig();
modelConfig = await loadModelConfig();
modelConfig = loadModelConfig();
const app = express();
app.use(express.text({ type: '*/*', limit: '12mb' }));
app.use((req, res, next) => {
@ -686,8 +746,6 @@ 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);

View file

@ -1,437 +0,0 @@
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

@ -1,97 +0,0 @@
<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,17 +1,39 @@
<script>
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 { onMount, tick } from 'svelte';
import Icon from './Icon.svelte';
import Field from './Field.svelte';
import Stat from './Stat.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 HealthConnect = registerPlugin('HealthConnect');
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',
};
let loginMode = location.pathname === '/login' || location.pathname === '/reset' || location.pathname === '/verify';
let username = 'admin';
@ -32,14 +54,12 @@
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 = '';
@ -48,9 +68,6 @@
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 = '';
@ -80,20 +97,17 @@
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)
@ -106,17 +120,22 @@
$: diaryDays = Array.from(new Set(diaryRows.map(entry => entry.date))).sort((a, b) => b.localeCompare(a));
onMount(async () => {
if (location.pathname === '/verify' && resetToken) await verifyAccountToken();
if (loginMode) return;
await loadSession();
if (location.pathname === '/verify' && resetToken) await verifyAccountToken();
page = localStorage.getItem('calorie_ai_last_tab') || 'dashboard';
setDefaultMealDateTime();
await loadSettingsFromServer();
await loadModels();
await Promise.all([loadEntriesFromServer(), loadActivitiesFromServer(), loadPlans()]);
await Promise.all([loadSettingsFromServer(), loadEntriesFromServer(), loadModels(), loadPlans()]);
await drawCharts();
});
$: 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')}`;
}
@ -245,17 +264,10 @@
async function loadSettingsFromServer() {
try {
const response = await fetch('/api/settings');
if (response.ok) settings = normalizeSettings(await response.json());
if (response.ok) settings = { ...defaultSettings, ...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', {
@ -278,37 +290,6 @@
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 || '';
@ -356,20 +337,6 @@
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.';
@ -406,63 +373,43 @@
}
async function addModel(model) {
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.';
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;
} finally {
modelActionId = '';
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;
}
async function removeModel(modelId) {
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.';
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;
} finally {
modelActionId = '';
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;
}
async function changePassword() {
@ -550,32 +497,15 @@
}
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: payload }),
body: JSON.stringify({ body }),
});
const text = await response.text();
if (!response.ok) throw new Error(readApiError(text, 'AI request failed'));
if (!response.ok) throw new Error(text);
const json = JSON.parse(text);
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;
}
return json.choices[0].message.content.trim();
}
async function requestImageEstimate() {
@ -593,44 +523,13 @@
});
}
function buildTodayHistory() {
const todayMeals = entries.filter(e => e.date === todayKey());
if (!todayMeals.length) return '';
let total = 0;
const lines = todayMeals.map(e => {
total += e.calories || 0;
return `- ${e.mealName || e.description}: ${e.calories || 0} kcal, P${e.proteinGrams || 0}g C${e.carbsGrams || 0}g F${e.fatGrams || 0}g`;
});
lines.push(`Total so far: ${total} kcal`);
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 = [
`Estimate nutrition for this meal.`,
`Description: ${description}`,
`Portion: ${measure}`,
`Image analysis: ${imageEstimate}`,
todayHistory ? `\nUser's meals logged today:\n${todayHistory}` : '',
].filter(Boolean).join('\n');
return chatCompletion({
model: settings.taskModel,
temperature: 0.1,
messages: [
{ role: 'system', content: 'You are a nutrition estimator. Output ONLY a raw JSON object — no markdown fences, no explanation, no text before or after.\nYou MUST use these exact key names: mealName, calories, proteinGrams, carbsGrams, fatGrams, fruitServings, vegetableServings, foodGroups, notes.\ncalories, proteinGrams, carbsGrams, fatGrams must be integers. fruitServings and vegetableServings must be numbers. foodGroups must be a short comma-separated string.\nUse the notes field for personalized dietary observations based on the user\'s meals logged today (e.g. macro balance, variety, progress toward goals).\nOutput format: {"mealName":"...","calories":0,"proteinGrams":0,"carbsGrams":0,"fatGrams":0,"fruitServings":0,"vegetableServings":0,"foodGroups":"...","notes":"..."}' },
{ role: 'user', content: userContent },
{ role: 'system', content: 'You are a nutrition estimator. Respond with ONLY a JSON object no markdown, no explanation, no extra text.\nRequired keys: mealName (string), calories (integer), proteinGrams (integer), carbsGrams (integer), fatGrams (integer), fruitServings (decimal), vegetableServings (decimal), foodGroups (string), notes (string).\nExample: {"mealName":"Grilled chicken and rice","calories":520,"proteinGrams":45,"carbsGrams":48,"fatGrams":10,"fruitServings":0,"vegetableServings":1.5,"foodGroups":"protein, grains","notes":"standard portion"}' },
{ role: 'user', content: `Estimate the nutrition for this meal:\nDescription: ${description}\nPortion: ${measure}\nAdditional context: ${imageEstimate}` },
],
});
}
@ -701,7 +600,6 @@
});
if (!response.ok) throw new Error((await response.json().catch(() => ({}))).error || 'Failed to save meal');
applyEntriesData(await response.json());
estimateDraft = emptyEstimate();
description = '';
measure = '';
selectedImageDataUrl = '';
@ -739,7 +637,7 @@
if (!existing) return;
const updated = {
...existing,
...normalizeEstimateDraft(),
...estimateDraft,
date: mealDate,
time: mealTime,
mealType,
@ -766,47 +664,6 @@
}
}
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();
@ -858,39 +715,17 @@
planStatus = 'Generating plan...';
planError = false;
try {
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);
if (!settings.heightCm || !settings.weightKg) throw new Error('Enter height and current weight first.');
await saveSettings();
const content = await chatCompletion({
model: settings.taskModel,
temperature: 0.2,
messages: [
{ 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.` },
{ 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'}.` },
],
});
const version = Math.max(0, ...plans.map(p => Number(p.version || 0))) + 1;
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() };
const plan = { id: entryId(), title: `Weight-loss plan ${plans.length + 1}`, version: plans.length + 1, content, createdAt: new Date().toISOString() };
await savePlan(plan);
planStatus = 'Plan generated.';
} catch (error) {
@ -918,7 +753,7 @@ Include: daily calorie target, maintenance calories, protein target, meal loggin
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'}\n\nRecent activity:\n${recentActivitySummary(15)}` },
{ 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'}` },
],
});
const version = Math.max(0, ...plans.map(plan => Number(plan.version || 0))) + 1;
@ -953,6 +788,52 @@ Include: daily calorie target, maintenance calories, protein target, meal loggin
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}
@ -1009,20 +890,113 @@ Include: daily calorie target, maintenance calories, protein target, meal loggin
</div>
</main>
{:else}
<AppShell {pages} {page} bind:menuOpen bind:sidebarCollapsed {selectPage} {logout}>
<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">
{#if page === 'dashboard'}
<DashboardPage
{today}
{activeCalorieTarget}
{targetPercent}
{macroTotal}
{produceToday}
{todayEntries}
{todayActivities}
{activityToday}
{weekAverage}
{selectPage}
/>
<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>
{:else if page === 'log'}
<MealForm
@ -1040,13 +1014,41 @@ Include: daily calorie target, maintenance calories, protein target, meal loggin
bind:estimateDraft
{chooseImage}
{saveMeal}
{saveManualMeal}
{saveMealEdits}
{cancelEdit}
/>
{:else if page === 'analytics'}
<AnalyticsPage {week} />
<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>
{:else if page === 'diary'}
<DiaryPage
@ -1105,14 +1107,10 @@ Include: daily calorie target, maintenance calories, protein target, meal loggin
{modelStatus}
{modelError}
{modelSearching}
{modelActionId}
bind:accountEmail
{accountVerified}
{accountStatus}
{accountError}
{healthConnectStatus}
{healthConnectError}
{healthConnectSyncing}
{passwordStatus}
{passwordError}
{resetPasswordValue}
@ -1126,10 +1124,23 @@ Include: daily calorie target, maintenance calories, protein target, meal loggin
{removeModel}
{saveAccountEmail}
{sendVerificationEmail}
{syncHealthConnect}
{changePassword}
{resetPassword}
/>
{/if}
</AppShell>
</main>
</div>
</div>
{/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>

View file

@ -1,79 +0,0 @@
<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

@ -1,109 +0,0 @@
<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,6 +82,10 @@
<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,7 +15,6 @@
export let estimateDraft;
export let chooseImage;
export let saveMeal;
export let saveManualMeal;
export let saveMealEdits;
export let cancelEdit;
</script>
@ -32,23 +31,11 @@
<Field label="Meal type"><select id="mealType" class="input" bind:value={mealType} required>{#each mealTypes as type}<option>{type}</option>{/each}</select></Field>
<Field className="md:col-span-3" label="Description"><textarea id="description" class="input min-h-32" bind:value={description} placeholder="Example: grilled salmon, rice, broccoli, berries"></textarea></Field>
<Field label="Portion or measure"><input id="measure" class="input" bind:value={measure} placeholder="Example: one plate, 450g, 2 cups"></Field>
<div class="flex flex-col gap-2">
<span class="text-sm font-semibold text-slate-700">Photo</span>
<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>
Choose photo
<input id="image" type="file" accept="image/*" class="sr-only" on:change={chooseImage}>
</label>
</div>
</div>
<Field label="Meal image"><input id="image" class="input" type="file" accept="image/*" on:change={chooseImage}></Field>
</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}
<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>
{#if editingEntryId}
<div class="grid gap-3 border-t border-slate-200 p-4 md:grid-cols-4">
<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>
@ -58,10 +45,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>
</div>
{/if}
<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,6 +1,5 @@
<script>
import Field from './Field.svelte';
import { calculateCaloriePlan, planSummary } from './calorie.js';
export let settings;
export let plans = [];
@ -14,67 +13,40 @@
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: '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 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 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: '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: 'Are my fine-tune notes stored?',
a: 'No. Notes are sent to the AI to create the next plan version, then discarded. Only the resulting plan text is saved.',
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.',
},
{
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.',
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.',
},
];
const knownHeadings = new Set([
'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',
'plan', 'personalized weight-loss plan', 'your details', 'daily calorie range',
'protein target', 'healthy habits', 'weekly review steps', 'safety notes',
'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(/^\d+\.\s+/, '')
.replace(/\*\*/g, '')
.replace(/^---+$/, '')
.trim();
return line.replace(/^#{1,6}\s*/, '').replace(/^[-*]\s+/, '').replace(/\*\*/g, '').replace(/^---+$/, '').trim();
}
function isHeading(line) {
@ -92,213 +64,103 @@
for (const line of lines) {
if (isHeading(line)) {
if (current.items.length) output.push(current);
current = { title: line.replace(/:$/, ''), items: [] };
current = { title: line.replace(/^\d+\.\s+/, '').replace(/:$/, ''), items: [] };
} else {
current.items.push(line);
}
}
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' });
output.push(current);
return output.filter(section => section.items.length || section.title !== 'Plan');
}
</script>
<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>
<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}
</div>
</section>
<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>
</div>
</section>
<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>
<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>
<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>
</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}
<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>
{: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">
<div class="border-t border-slate-200 pt-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>
<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>
<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>
<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>
</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>
{:else}
<div class="empty-state">No plans yet. Fill in your profile and click Generate.</div>
{/if}
</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>
{/if}
</div>
{/each}
</div>
</section>
</section>

View file

@ -8,14 +8,10 @@
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;
@ -29,7 +25,6 @@
export let removeModel;
export let saveAccountEmail;
export let sendVerificationEmail;
export let syncHealthConnect;
export let changePassword;
export let resetPassword;
</script>
@ -70,7 +65,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 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}
{#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}
</div>
</div>
</div>
@ -78,28 +73,10 @@
<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 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}
{#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}
</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>

View file

@ -1,37 +0,0 @@
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: '',
};

View file

@ -1,66 +0,0 @@
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,17 +6,15 @@ 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,
models: [
{ id: 'claude-haiku-4.5', name: 'Claude Haiku 4.5' },
],
}),
});
});
@ -37,28 +35,19 @@ 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,
models: [
{ id: 'claude-haiku-4.5', name: 'Claude Haiku 4.5' },
{ id: body.id, name: body.name || body.id },
],
}),
});
});
await page.route('**/api/models/remove', async (route) => {
const body = route.request().postDataJSON();
models = models.filter(model => model.id !== body.id);
if (!models.length) models = [{ id: 'claude-haiku-4.5', name: 'Claude Haiku 4.5' }];
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ ok: true, defaultModel: models[0].id, models }),
});
});
}
async function mockPlans(page) {
@ -118,18 +107,6 @@ 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) => {
@ -145,7 +122,6 @@ 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$/);
@ -198,15 +174,9 @@ 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();
@ -265,14 +235,11 @@ 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: 'Add' }).last().click();
await expect(page.locator('#modelStatus')).toContainText('Added Gemini 2.5 Flash.');
await page.getByRole('button', { name: '+' }).last().click();
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');