diff --git a/.forgejo/workflows/web.yml b/.forgejo/workflows/web.yml index e36170e..45ca0ff 100644 --- a/.forgejo/workflows/web.yml +++ b/.forgejo/workflows/web.yml @@ -19,7 +19,10 @@ jobs: - name: Install Node and browsers run: | apt-get update - apt-get install -y --no-install-recommends ca-certificates curl nodejs npm + apt-get install -y --no-install-recommends ca-certificates curl nodejs npm postgresql postgresql-client + service postgresql start + su - postgres -c "psql -tc \"SELECT 1 FROM pg_roles WHERE rolname='calorie_ai'\" | grep -q 1 || psql -c \"CREATE USER calorie_ai WITH PASSWORD 'calorie_ai';\"" + su - postgres -c "psql -tc \"SELECT 1 FROM pg_database WHERE datname='calorie_ai_test'\" | grep -q 1 || createdb -O calorie_ai calorie_ai_test" cd web npm install npx playwright install --with-deps chromium @@ -31,4 +34,5 @@ jobs: CALORIE_AI_WEB_USER=admin \ CALORIE_AI_WEB_PASSWORD=test-password \ CALORIE_AI_SESSION_SECRET=test-session-secret \ + CALORIE_AI_TEST_DATABASE_URL=postgresql://calorie_ai:calorie_ai@127.0.0.1:5432/calorie_ai_test \ npm test diff --git a/.gitignore b/.gitignore index 0c0632c..e6383d2 100644 --- a/.gitignore +++ b/.gitignore @@ -8,5 +8,6 @@ local.properties web/node_modules/ web/test-results/ web/playwright-report/ +web/.test-data/ web/data/ web/.env diff --git a/app/src/main/java/com/danvics/calorieai/MainActivity.kt b/app/src/main/java/com/danvics/calorieai/MainActivity.kt index c87a4d2..834a711 100644 --- a/app/src/main/java/com/danvics/calorieai/MainActivity.kt +++ b/app/src/main/java/com/danvics/calorieai/MainActivity.kt @@ -243,18 +243,21 @@ private fun AppRoot(repo: ApiRepository) { }, onGeneratePlan = { val settings = appState.settings - if (settings.heightCm.isBlank() || settings.weightKg.isBlank()) { - planStatus = "Enter height and current weight in Settings first." + val calculated = calculateCalorieTarget(settings) + if (calculated == null) { + planStatus = "Enter sex, age, height, and current weight in Settings first." return@CalorieAiApp } + val planSettings = if (settings.calorieTarget.isBlank()) settings.copy(calorieTarget = calculated.goalCalories.toString()) else settings planBusy = true planStatus = "Generating plan..." scope.launch(Dispatchers.IO) { runCatching { + if (planSettings != settings) repo.saveSettings(planSettings) val recentMeals = appState.entries.takeLast(10) .joinToString("; ") { "${it.date} ${it.mealType}: ${it.estimate.mealName}, ${it.estimate.calories} kcal" } .ifBlank { "none logged yet" } - val content = repo.chat(buildPlanBody(settings.taskModel, settings, recentMeals)) + val content = repo.chat(buildPlanBody(planSettings.taskModel, planSettings, recentMeals)) val plan = Plan( id = UUID.randomUUID().toString(), title = "Plan ${appState.plans.plans.size + 1}", @@ -264,7 +267,7 @@ private fun AppRoot(repo: ApiRepository) { ) repo.savePlan(plan) }.onSuccess { plans -> - withContext(Dispatchers.Main) { appState = appState.copy(plans = plans); planStatus = "Plan generated."; planBusy = false } + withContext(Dispatchers.Main) { appState = appState.copy(plans = plans, settings = planSettings); planStatus = "Plan generated."; planBusy = false } }.onFailure { e -> withContext(Dispatchers.Main) { if (e is UnauthorizedException) handleUnauth() @@ -375,9 +378,25 @@ private fun buildNutritionBody(model: String, description: String, measure: Stri 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-loss guidance. Do not diagnose disease. Use plain text section titles. Do not use markdown symbols. Recommend medical review for pregnancy, eating disorders, chronic disease, or aggressive goals.")) + .put(JSONObject().put("role", "system").put("content", "Create safe, concise weight-management guidance. Do not diagnose disease. Use plain text section titles. Do not use markdown symbols. Recommend medical review for pregnancy, eating disorders, chronic disease, or aggressive goals. Make it feel like a personalized plan built from onboarding answers, not a generic article.")) .put(JSONObject().put("role", "user").put("content", - "Create a personalized weight-loss plan: height ${settings.heightCm} cm, weight ${settings.weightKg} kg, target ${settings.targetWeightKg.ifBlank { "not set" }} kg, activity ${settings.activityLevel}, pace ${settings.pace}. Include daily calorie range, protein target, habits, weekly review steps, safety notes. Recent meals: $recentMeals" + "Create a personalized plan using these details:\n" + + "Goal: ${settings.goal}\n" + + "Sex: ${settings.sex}\n" + + "Age: ${settings.age}\n" + + "Height: ${settings.heightCm} cm\n" + + "Current weight: ${settings.weightKg} kg\n" + + "Target weight: ${settings.targetWeightKg.ifBlank { "not set" }} kg\n" + + "Activity: ${settings.activityLevel}\n" + + "Pace: ${settings.pace}\n" + + "Calculated targets: ${calorieTargetSummary(settings)}\n" + + "Motivation: ${settings.motivation.ifBlank { "not set" }}\n" + + "Biggest challenge: ${settings.challenge.ifBlank { "not set" }}\n" + + "Tracking style: ${settings.trackingStyle.ifBlank { "not set" }}\n" + + "Calorie-counting experience: ${settings.calorieCountingExperience.ifBlank { "not set" }}\n" + + "Fasting interest: ${settings.fastingInterest.ifBlank { "not set" }}\n" + + "Recent meals: $recentMeals\n\n" + + "Include: daily calorie target, maintenance calories, protein target, meal logging strategy, challenge-specific tactics, weekly review steps, and safety notes." ))) private fun buildTuneBody(model: String, currentContent: String, note: String, recentMeals: String): JSONObject = diff --git a/app/src/main/java/com/danvics/calorieai/data/AppRepository.kt b/app/src/main/java/com/danvics/calorieai/data/AppRepository.kt index 9c7a2eb..642385e 100644 --- a/app/src/main/java/com/danvics/calorieai/data/AppRepository.kt +++ b/app/src/main/java/com/danvics/calorieai/data/AppRepository.kt @@ -75,12 +75,20 @@ class ApiRepository(private val prefs: SharedPreferences) { fun saveSettings(settings: ServerSettings): ServerSettings = parseSettings(JSONObject(post("/api/settings", JSONObject() + .put("sex", settings.sex) + .put("age", settings.age) .put("heightCm", settings.heightCm) .put("weightKg", settings.weightKg) .put("targetWeightKg", settings.targetWeightKg) + .put("goal", settings.goal) .put("calorieTarget", settings.calorieTarget) .put("activityLevel", settings.activityLevel) .put("pace", settings.pace) + .put("motivation", settings.motivation) + .put("challenge", settings.challenge) + .put("trackingStyle", settings.trackingStyle) + .put("calorieCountingExperience", settings.calorieCountingExperience) + .put("fastingInterest", settings.fastingInterest) .toString()))) fun getPlans(): PlansState = parsePlans(JSONObject(get("/api/plans"))) @@ -147,12 +155,20 @@ class ApiRepository(private val prefs: SharedPreferences) { private fun parseSettings(json: JSONObject) = ServerSettings( visionModel = json.optString("visionModel"), taskModel = json.optString("taskModel"), + sex = json.optString("sex"), + age = json.optString("age"), heightCm = json.optString("heightCm"), weightKg = json.optString("weightKg"), targetWeightKg = json.optString("targetWeightKg"), + goal = json.optString("goal", "Lose weight").ifBlank { "Lose weight" }, calorieTarget = json.optString("calorieTarget"), activityLevel = json.optString("activityLevel", "Moderate").ifBlank { "Moderate" }, - pace = json.optString("pace", "Steady").ifBlank { "Steady" } + pace = json.optString("pace", "Steady").ifBlank { "Steady" }, + motivation = json.optString("motivation"), + challenge = json.optString("challenge"), + trackingStyle = json.optString("trackingStyle"), + calorieCountingExperience = json.optString("calorieCountingExperience"), + fastingInterest = json.optString("fastingInterest") ) private fun parsePlans(json: JSONObject): PlansState { diff --git a/app/src/main/java/com/danvics/calorieai/data/CalorieTargets.kt b/app/src/main/java/com/danvics/calorieai/data/CalorieTargets.kt new file mode 100644 index 0000000..a0b2e1a --- /dev/null +++ b/app/src/main/java/com/danvics/calorieai/data/CalorieTargets.kt @@ -0,0 +1,66 @@ +package com.danvics.calorieai.data + +import kotlin.math.abs +import kotlin.math.ceil +import kotlin.math.roundToInt + +data class CalorieTarget( + val bmr: Int, + val maintenanceCalories: Int, + val goalCalories: Int, + val proteinTarget: Int, + val weeklyKg: Double, + val weeksToTarget: Int +) + +fun calculateCalorieTarget(settings: ServerSettings): CalorieTarget? { + val weight = settings.weightKg.toDoubleOrNull()?.takeIf { it > 0 } ?: return null + val height = settings.heightCm.toDoubleOrNull()?.takeIf { it > 0 } ?: return null + val age = settings.age.toDoubleOrNull()?.takeIf { it > 0 } ?: return null + if (settings.sex.isBlank()) return null + + val bmr = 10 * weight + 6.25 * height - 5 * age + if (settings.sex == "Male") 5 else -161 + val maintenance = roundTo(bmr * activityFactor(settings.activityLevel)) + val goalCalories = maxOf(if (settings.sex == "Male") 1500 else 1200, roundTo(maintenance.toDouble() + paceDelta(settings.pace, settings.goal))) + val protein = roundTo(weight * if (settings.goal == "Build muscle") 1.8 else if (settings.goal == "Maintain weight") 1.3 else 1.6, 5) + val weeklyKg = weeklyChangeKg(settings.pace, settings.goal) + val targetWeight = settings.targetWeightKg.toDoubleOrNull() ?: 0.0 + val remaining = if (targetWeight > 0) targetWeight - weight else 0.0 + val weeks = if (remaining != 0.0 && weeklyKg != 0.0 && remaining.sign() == weeklyKg.sign()) ceil(abs(remaining / weeklyKg)).toInt() else 0 + + return CalorieTarget(roundTo(bmr), maintenance, goalCalories, protein, weeklyKg, weeks) +} + +fun calorieTargetSummary(settings: ServerSettings): String { + val target = calculateCalorieTarget(settings) ?: return "Add sex, age, height, weight, and activity level to calculate a daily target." + val pace = when { + target.weeklyKg > 0 -> "gain about ${"%.2f".format(target.weeklyKg)} kg/week" + target.weeklyKg < 0 -> "lose about ${"%.2f".format(abs(target.weeklyKg))} kg/week" + else -> "maintain weight" + } + val weeks = if (target.weeksToTarget > 0) " Estimated time to target: ${target.weeksToTarget} weeks." else "" + return "${target.goalCalories} kcal/day, ${target.proteinTarget} g protein/day, $pace.$weeks" +} + +private fun activityFactor(level: String) = when (level) { + "Low" -> 1.2 + "Moderate" -> 1.55 + "High" -> 1.725 + else -> 1.375 +} + +private fun paceDelta(pace: String, goal: String) = when (goal) { + "Gain weight", "Build muscle" -> if (pace == "Aggressive") 500 else if (pace == "Steady") 350 else 250 + "Maintain weight" -> 0 + else -> if (pace == "Aggressive") -750 else if (pace == "Steady") -500 else -250 +} + +private fun weeklyChangeKg(pace: String, goal: String): Double { + if (goal == "Maintain weight") return 0.0 + val amount = if (pace == "Aggressive") 0.75 else if (pace == "Steady") 0.5 else 0.25 + return if (goal == "Gain weight" || goal == "Build muscle") amount else -amount +} + +private fun roundTo(value: Double, step: Int = 10) = (value / step).roundToInt() * step + +private fun Double.sign() = if (this > 0) 1 else if (this < 0) -1 else 0 diff --git a/app/src/main/java/com/danvics/calorieai/data/Models.kt b/app/src/main/java/com/danvics/calorieai/data/Models.kt index 4fd2bdc..407dcaa 100644 --- a/app/src/main/java/com/danvics/calorieai/data/Models.kt +++ b/app/src/main/java/com/danvics/calorieai/data/Models.kt @@ -62,12 +62,20 @@ data class PlansState( data class ServerSettings( val visionModel: String = "", val taskModel: String = "", + val sex: String = "", + val age: String = "", val heightCm: String = "", val weightKg: String = "", val targetWeightKg: String = "", + val goal: String = "Lose weight", val calorieTarget: String = "", val activityLevel: String = "Moderate", - val pace: String = "Steady" + val pace: String = "Steady", + val motivation: String = "", + val challenge: String = "", + val trackingStyle: String = "", + val calorieCountingExperience: String = "", + val fastingInterest: String = "" ) data class AppState( diff --git a/app/src/main/java/com/danvics/calorieai/ui/DashboardScreen.kt b/app/src/main/java/com/danvics/calorieai/ui/DashboardScreen.kt index fd155e4..b673123 100644 --- a/app/src/main/java/com/danvics/calorieai/ui/DashboardScreen.kt +++ b/app/src/main/java/com/danvics/calorieai/ui/DashboardScreen.kt @@ -9,6 +9,7 @@ import androidx.compose.ui.unit.dp 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 @@ -17,7 +18,7 @@ fun DashboardScreen(entries: List, settings: ServerSettings, onLog: ( val week = MealStats.lastSevenDays(entries) val avgCalories = if (week.isEmpty()) 0 else week.sumOf { it.calories } / week.size val macroTotal = today.protein + today.carbs + today.fat - val calorieTarget = settings.calorieTarget.toIntOrNull() ?: 0 + val calorieTarget = settings.calorieTarget.toIntOrNull() ?: calculateCalorieTarget(settings)?.goalCalories ?: 0 Card( modifier = Modifier.fillMaxWidth(), diff --git a/app/src/main/java/com/danvics/calorieai/ui/PlansScreen.kt b/app/src/main/java/com/danvics/calorieai/ui/PlansScreen.kt index 1164bde..8f55d19 100644 --- a/app/src/main/java/com/danvics/calorieai/ui/PlansScreen.kt +++ b/app/src/main/java/com/danvics/calorieai/ui/PlansScreen.kt @@ -9,6 +9,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.danvics.calorieai.data.PlansState import com.danvics.calorieai.data.ServerSettings +import com.danvics.calorieai.data.calculateCalorieTarget +import com.danvics.calorieai.data.calorieTargetSummary @Composable fun PlansScreen( @@ -22,6 +24,7 @@ fun PlansScreen( onDelete: (String) -> Unit ) = Page { val selectedPlan = plansState.plans.find { it.id == plansState.selectedPlanId } ?: plansState.plans.firstOrNull() + val calorieTarget = calculateCalorieTarget(settings) var tuneNote by remember { mutableStateOf("") } var showDeleteConfirm by remember { mutableStateOf(false) } @@ -44,18 +47,21 @@ fun PlansScreen( SectionCard("Generate plan") { Text( - "Your plan is built from your profile and recent meals, then updated as you log and progress.", + "Your plan is built from your body stats, goal, habits, challenges, and recent meals.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) + if (calorieTarget != null) { + Text(calorieTargetSummary(settings), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary) + } Button( - enabled = !planBusy && settings.heightCm.isNotBlank() && settings.weightKg.isNotBlank(), + enabled = !planBusy && calorieTarget != null, onClick = onGenerate, modifier = Modifier.fillMaxWidth() ) { Text(if (planBusy) "Generating..." else "Generate new plan") } - if (settings.heightCm.isBlank() || settings.weightKg.isBlank()) { + if (calorieTarget == null) { Text( - "Fill in your height and weight in Settings first.", + "Fill in sex, age, height, and weight in Settings first.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error ) diff --git a/app/src/main/java/com/danvics/calorieai/ui/SettingsScreen.kt b/app/src/main/java/com/danvics/calorieai/ui/SettingsScreen.kt index 8014859..a04d5ad 100644 --- a/app/src/main/java/com/danvics/calorieai/ui/SettingsScreen.kt +++ b/app/src/main/java/com/danvics/calorieai/ui/SettingsScreen.kt @@ -5,11 +5,20 @@ 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( @@ -29,6 +38,10 @@ fun SettingsScreen( } SectionCard("Profile") { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + DropdownField("Sex", SEX_OPTIONS, draft.sex, { draft = draft.copy(sex = it) }, modifier = Modifier.weight(1f)) + Field("Age", draft.age, { draft = draft.copy(age = it) }, modifier = Modifier.weight(1f), placeholder = "36") + } Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Field("Height (cm)", draft.heightCm, { draft = draft.copy(heightCm = it) }, modifier = Modifier.weight(1f), placeholder = "175") Field("Weight (kg)", draft.weightKg, { draft = draft.copy(weightKg = it) }, modifier = Modifier.weight(1f), placeholder = "80") @@ -37,12 +50,28 @@ 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("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) diff --git a/web/Dockerfile b/web/Dockerfile index 785f200..1a3075b 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -4,6 +4,7 @@ WORKDIR /app COPY package*.json ./ RUN npm ci COPY index.html vite.config.js server.js ./ +COPY server ./server COPY src ./src COPY public ./public RUN npm run build && npm prune --omit=dev diff --git a/web/docker-compose.yml b/web/docker-compose.yml index 6fe9ed0..48ae5b0 100644 --- a/web/docker-compose.yml +++ b/web/docker-compose.yml @@ -1,11 +1,33 @@ 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: http://litellm:4000/v1 LITELLM_MODEL: openrouter-claude-haiku-4.5 ports: diff --git a/web/package-lock.json b/web/package-lock.json index 62e3392..f2f2f59 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -10,7 +10,8 @@ "dependencies": { "argon2": "^0.44.0", "express": "^5.2.1", - "nodemailer": "^7.0.11" + "nodemailer": "^7.0.11", + "pg": "^8.21.0" }, "devDependencies": { "@capacitor/android": "^8.3.4", @@ -2579,6 +2580,95 @@ "dev": true, "license": "MIT" }, + "node_modules/pg": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz", + "integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.13.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.14.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz", + "integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz", + "integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2675,6 +2765,45 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -3073,7 +3202,6 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", - "dev": true, "license": "ISC", "engines": { "node": ">= 10.x" @@ -3528,6 +3656,15 @@ "node": ">=8.0" } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/yallist": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", diff --git a/web/package.json b/web/package.json index df2a302..c591e08 100644 --- a/web/package.json +++ b/web/package.json @@ -23,6 +23,7 @@ "dependencies": { "argon2": "^0.44.0", "express": "^5.2.1", - "nodemailer": "^7.0.11" + "nodemailer": "^7.0.11", + "pg": "^8.21.0" } } diff --git a/web/playwright.config.js b/web/playwright.config.js index a0aa199..c023252 100644 --- a/web/playwright.config.js +++ b/web/playwright.config.js @@ -12,6 +12,8 @@ module.exports = defineConfig({ CALORIE_AI_WEB_USER: 'admin', CALORIE_AI_WEB_PASSWORD: 'test-password', CALORIE_AI_SESSION_SECRET: 'test-session-secret', + CALORIE_AI_DATABASE_URL: process.env.CALORIE_AI_TEST_DATABASE_URL || 'postgresql://calorie_ai@127.0.0.1:55432/calorie_ai', + CALORIE_AI_DATA_DIR: '.test-data', }, }, use: { diff --git a/web/server.js b/web/server.js index e4ecf7a..257b92d 100644 --- a/web/server.js +++ b/web/server.js @@ -4,6 +4,7 @@ const fs = require('fs'); const path = require('path'); const argon2 = require('argon2'); const nodemailer = require('nodemailer'); +const { createStore, defaultSettings } = require('./server/storage'); const port = Number(process.env.PORT || 8080); const publicDir = fs.existsSync(path.join(__dirname, 'dist')) ? path.join(__dirname, 'dist') : path.join(__dirname, 'public'); @@ -18,6 +19,7 @@ const sessionCookieName = 'calorie_ai_session'; const sessionSeconds = 60 * 60 * 24 * 7; let auth; let modelConfig; +let store; const aiConfig = { baseUrl: (process.env.CALORIE_AI_API_BASE_URL || process.env.LITELLM_API_BASE || 'https://llm.danvics.com/v1').replace(/\/+$/, ''), apiKey: process.env.CALORIE_AI_API_KEY || process.env.LITELLM_API_KEY || process.env.OPENAI_API_KEY || '', @@ -50,23 +52,12 @@ function readBody(req) { }); } -function readJsonFile(file, fallback) { - try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return fallback; } -} - -function writePrivateJson(file, value) { - fs.mkdirSync(path.dirname(file), { recursive: true }); - fs.writeFileSync(file, JSON.stringify(value, null, 2)); - fs.chmodSync(file, 0o600); -} - async function hashPassword(password) { return argon2.hash(password, { type: argon2.argon2id }); } async function loadAuthConfig() { - let stored = {}; - stored = readJsonFile(authFile, {}); + const stored = await store.getAuthConfig(); const user = process.env.CALORIE_AI_WEB_USER || stored.user || 'admin'; const email = process.env.CALORIE_AI_ADMIN_EMAIL || stored.email || process.env.MAIL_FROM || process.env.SMTP_FROM || ''; @@ -86,7 +77,7 @@ async function loadAuthConfig() { const sessionSecret = process.env.CALORIE_AI_SESSION_SECRET || stored.sessionSecret || crypto.randomBytes(32).toString('hex'); if (!stored.passwordHash || !stored.sessionSecret || stored.password) { - writePrivateJson(authFile, { + await store.saveAuthConfig({ user, email, emailVerified: stored.emailVerified === true, @@ -96,15 +87,15 @@ async function loadAuthConfig() { updatedAt: new Date().toISOString(), ...(plainPassword && !stored.passwordHash ? { initialPassword: plainPassword } : {}), }); - console.log(`Calorie AI auth is stored at ${authFile}`); + console.log(`Calorie AI auth is stored in ${store.databaseLabel}`); } return { ...stored, user, email, emailVerified: stored.emailVerified === true, passwordHash, sessionSecret }; } -function saveAuthConfig(extra = {}) { +async function saveAuthConfig(extra = {}) { auth = { ...auth, ...extra, updatedAt: new Date().toISOString() }; - writePrivateJson(authFile, { + await store.saveAuthConfig({ user: auth.user, email: auth.email || '', emailVerified: auth.emailVerified === true, @@ -210,41 +201,29 @@ async function sendMail(to, subject, text, html = '') { await transporter.sendMail({ from: config.from, to, subject, text, html: html || undefined }); } -function loadModelConfig() { - const stored = readJsonFile(modelsFile, {}); +async function loadModelConfig() { + const stored = await store.getModelConfig(); const defaultModel = stored.defaultModel || aiConfig.defaultModel; const models = Array.isArray(stored.models) && stored.models.length ? stored.models : [{ id: defaultModel, name: defaultModel, tag: 'DEFAULT' }]; const config = { defaultModel, models: dedupeModels(models) }; - if (!fs.existsSync(modelsFile)) saveModelConfig(config); + if (!stored.models?.length) await saveModelConfig(config); return config; } -function saveModelConfig(config = modelConfig) { - writePrivateJson(modelsFile, { ...config, updatedAt: new Date().toISOString() }); -} - -function loadPlanConfig() { - const stored = readJsonFile(plansFile, {}); - return { - plans: Array.isArray(stored.plans) ? stored.plans : [], - selectedPlanId: stored.selectedPlanId || '', - }; -} - -function savePlanConfig(config) { - writePrivateJson(plansFile, { ...config, updatedAt: new Date().toISOString() }); +async function saveModelConfig(config = modelConfig) { + await store.saveModelConfig({ ...config, updatedAt: new Date().toISOString() }); } async function getPlans(req, res) { - send(res, 200, JSON.stringify(loadPlanConfig())); + send(res, 200, JSON.stringify(await store.getPlans())); } async function createPlan(req, res) { const payload = JSON.parse(await readBody(req) || '{}'); if (!payload.content) return send(res, 400, JSON.stringify({ error: 'Plan content required' })); - const config = loadPlanConfig(); + const config = await store.getPlans(); const plan = { id: payload.id || crypto.randomUUID(), title: String(payload.title || `Weight-loss plan ${config.plans.length + 1}`), @@ -253,91 +232,47 @@ async function createPlan(req, res) { previousPlanId: payload.previousPlanId || undefined, createdAt: payload.createdAt || new Date().toISOString(), }; - config.plans = [plan, ...config.plans]; - config.selectedPlanId = plan.id; - savePlanConfig(config); - send(res, 200, JSON.stringify(config)); + send(res, 200, JSON.stringify(await store.savePlan(plan))); } async function selectPlanRoute(req, res) { const payload = JSON.parse(await readBody(req) || '{}'); - const config = loadPlanConfig(); - if (payload.id && !config.plans.some(plan => plan.id === payload.id)) return send(res, 404, JSON.stringify({ error: 'Plan not found' })); - config.selectedPlanId = payload.id || ''; - savePlanConfig(config); - send(res, 200, JSON.stringify(config)); + if (payload.id && !await store.planExists(payload.id)) return send(res, 404, JSON.stringify({ error: 'Plan not found' })); + send(res, 200, JSON.stringify(await store.selectPlan(payload.id || ''))); } async function deletePlanRoute(req, res) { const payload = JSON.parse(await readBody(req) || '{}'); - const config = loadPlanConfig(); - config.plans = config.plans.filter(plan => plan.id !== payload.id); - if (config.selectedPlanId === payload.id) config.selectedPlanId = config.plans[0]?.id || ''; - savePlanConfig(config); - send(res, 200, JSON.stringify(config)); -} - -function loadSettings() { - return readJsonFile(settingsFile, { - visionModel: '', - taskModel: '', - heightCm: '', - weightKg: '', - targetWeightKg: '', - calorieTarget: '', - activityLevel: 'Moderate', - pace: 'Steady', - }); -} - -function saveSettingsData(settings) { - writePrivateJson(settingsFile, { ...settings, updatedAt: new Date().toISOString() }); + send(res, 200, JSON.stringify(await store.deletePlan(payload.id))); } async function getSettings(req, res) { - send(res, 200, JSON.stringify(loadSettings())); + send(res, 200, JSON.stringify(await store.getSettings())); } async function updateSettings(req, res) { try { const payload = JSON.parse(await readBody(req) || '{}'); - const current = loadSettings(); - const allowed = ['visionModel', 'taskModel', 'heightCm', 'weightKg', 'targetWeightKg', 'calorieTarget', 'activityLevel', 'pace']; + const current = await store.getSettings(); + const allowed = Object.keys(defaultSettings); const updated = { ...current }; for (const key of allowed) if (payload[key] !== undefined) updated[key] = payload[key]; - saveSettingsData(updated); + await store.saveSettings(updated); send(res, 200, JSON.stringify(updated)); } catch (error) { send(res, 400, JSON.stringify({ error: error.message })); } } -function loadEntriesData() { - const stored = readJsonFile(entriesFile, {}); - return { - entries: Array.isArray(stored.entries) ? stored.entries : [], - trash: Array.isArray(stored.trash) ? stored.trash : [], - }; -} - -function saveEntriesData(data) { - writePrivateJson(entriesFile, { ...data, updatedAt: new Date().toISOString() }); -} - async function getEntriesRoute(req, res) { - send(res, 200, JSON.stringify(loadEntriesData())); + send(res, 200, JSON.stringify(await store.getEntries())); } async function upsertEntryRoute(req, res) { try { const payload = JSON.parse(await readBody(req) || '{}'); if (!payload.id) return send(res, 400, JSON.stringify({ error: 'Entry ID required' })); - const data = loadEntriesData(); - const idx = data.entries.findIndex(e => e.id === payload.id); - if (idx >= 0) data.entries[idx] = { ...data.entries[idx], ...payload }; - else data.entries = [...data.entries, payload]; - saveEntriesData(data); - send(res, 200, JSON.stringify(data)); + send(res, 200, JSON.stringify(await store.upsertMealEntry(payload))); } catch (error) { send(res, 400, JSON.stringify({ error: error.message })); } @@ -347,12 +282,7 @@ async function deleteEntriesRoute(req, res) { try { const payload = JSON.parse(await readBody(req) || '{}'); const ids = new Set(Array.isArray(payload.ids) ? payload.ids : [payload.id].filter(Boolean)); - const data = loadEntriesData(); - const moving = data.entries.filter(e => ids.has(e.id)).map(e => ({ ...e, trashedAt: new Date().toISOString() })); - data.entries = data.entries.filter(e => !ids.has(e.id)); - data.trash = [...moving, ...data.trash]; - saveEntriesData(data); - send(res, 200, JSON.stringify(data)); + send(res, 200, JSON.stringify(await store.moveEntriesToTrash([...ids]))); } catch (error) { send(res, 400, JSON.stringify({ error: error.message })); } @@ -361,13 +291,8 @@ async function deleteEntriesRoute(req, res) { async function restoreEntryRoute(req, res) { try { const payload = JSON.parse(await readBody(req) || '{}'); - const data = loadEntriesData(); - const found = data.trash.find(e => e.id === payload.id); - if (!found) return send(res, 404, JSON.stringify({ error: 'Entry not found in trash' })); - const { trashedAt, ...restored } = found; - data.entries = [restored, ...data.entries]; - data.trash = data.trash.filter(e => e.id !== payload.id); - saveEntriesData(data); + const data = await store.restoreEntry(payload.id); + if (!data) return send(res, 404, JSON.stringify({ error: 'Entry not found in trash' })); send(res, 200, JSON.stringify(data)); } catch (error) { send(res, 400, JSON.stringify({ error: error.message })); @@ -376,10 +301,7 @@ async function restoreEntryRoute(req, res) { async function clearTrashRoute(req, res) { try { - const data = loadEntriesData(); - data.trash = []; - saveEntriesData(data); - send(res, 200, JSON.stringify(data)); + send(res, 200, JSON.stringify(await store.clearTrash())); } catch (error) { send(res, 400, JSON.stringify({ error: error.message })); } @@ -400,7 +322,7 @@ function send(res, status, body, contentType = 'application/json; charset=utf-8' 'x-content-type-options': 'nosniff', 'referrer-policy': 'same-origin', 'x-frame-options': 'DENY', - 'content-security-policy': "default-src 'self'; img-src 'self' data: blob:; script-src 'self'; style-src 'self'; connect-src 'self'; base-uri 'self'; frame-ancestors 'none'", + 'content-security-policy': "default-src 'self'; img-src 'self' data: blob:; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; base-uri 'self'; frame-ancestors 'none'", ...headers, }); res.end(headOnly ? '' : body); @@ -561,7 +483,7 @@ async function addModel(req, res) { if (!payload.id) return send(res, 400, JSON.stringify({ error: 'Model ID required' })); modelConfig.models = dedupeModels([...modelConfig.models, { id: payload.id, name: payload.name || payload.id, tag: 'ADDED' }]); if (!modelConfig.defaultModel) modelConfig.defaultModel = payload.id; - saveModelConfig(); + await saveModelConfig(); send(res, 200, JSON.stringify({ ok: true, models: modelConfig.models, defaultModel: modelConfig.defaultModel })); } catch (error) { send(res, 400, JSON.stringify({ error: error.message })); @@ -574,7 +496,7 @@ async function removeModel(req, res) { modelConfig.models = modelConfig.models.filter(model => model.id !== payload.id); if (!modelConfig.models.length) modelConfig.models = [{ id: aiConfig.defaultModel, name: aiConfig.defaultModel, tag: 'DEFAULT' }]; if (!modelConfig.models.some(model => model.id === modelConfig.defaultModel)) modelConfig.defaultModel = modelConfig.models[0].id; - saveModelConfig(); + await saveModelConfig(); send(res, 200, JSON.stringify({ ok: true, models: modelConfig.models, defaultModel: modelConfig.defaultModel })); } catch (error) { send(res, 400, JSON.stringify({ error: error.message })); @@ -604,7 +526,7 @@ async function changePassword(req, res) { if (!await argon2.verify(auth.passwordHash, payload.currentPassword || '')) return send(res, 401, JSON.stringify({ error: 'Current password is incorrect' })); if (!payload.newPassword || payload.newPassword.length < 8) return send(res, 400, JSON.stringify({ error: 'Use at least 8 characters for the new password' })); auth.passwordHash = await hashPassword(payload.newPassword); - saveAuthConfig({ passwordHash: auth.passwordHash, resetTokenHash: undefined, resetExpiresAt: undefined }); + await saveAuthConfig({ passwordHash: auth.passwordHash, resetTokenHash: undefined, resetExpiresAt: undefined }); send(res, 200, JSON.stringify({ ok: true })); } catch (error) { send(res, 400, JSON.stringify({ error: error.message })); @@ -615,7 +537,7 @@ async function resetPassword(req, res) { try { if (!auth.email) return send(res, 400, JSON.stringify({ error: 'Set an account email first' })); const token = crypto.randomBytes(32).toString('base64url'); - saveAuthConfig({ resetTokenHash: tokenHash(token), resetExpiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString() }); + await saveAuthConfig({ resetTokenHash: tokenHash(token), resetExpiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString() }); const link = `${appOrigin(req)}/reset?token=${encodeURIComponent(token)}`; const text = `Use this link to reset your Calorie AI password. It expires in 1 hour.\n\n${link}`; const html = actionEmailHtml('Password reset request', 'Someone requested a password reset for your Calorie AI account. If that was you, click the button below to choose a new password. This link expires in 1 hour. If you did not request this, no action is needed.', link, 'Reset My Password'); @@ -635,7 +557,7 @@ async function requestPasswordReset(req, res) { } if (!auth.email) return send(res, 200, JSON.stringify({ ok: true, message: 'If the account exists, a reset email was sent.' })); const token = crypto.randomBytes(32).toString('base64url'); - saveAuthConfig({ resetTokenHash: tokenHash(token), resetExpiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString() }); + await saveAuthConfig({ resetTokenHash: tokenHash(token), resetExpiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString() }); const link = `${appOrigin(req)}/reset?token=${encodeURIComponent(token)}`; const text = `Use this link to reset your Calorie AI password. It expires in 1 hour.\n\n${link}`; const html = actionEmailHtml('Password reset request', 'Someone requested a password reset for your Calorie AI account. If that was you, click the button below to choose a new password. This link expires in 1 hour. If you did not request this, no action is needed.', link, 'Reset My Password'); @@ -653,7 +575,7 @@ async function confirmPasswordReset(req, res) { const valid = auth.resetTokenHash && auth.resetExpiresAt && new Date(auth.resetExpiresAt).getTime() > Date.now() && safeEqual(auth.resetTokenHash, tokenHash(payload.token || '')); if (!valid) return send(res, 400, JSON.stringify({ error: 'Reset link is invalid or expired' })); const passwordHash = await hashPassword(payload.newPassword); - saveAuthConfig({ passwordHash, resetTokenHash: undefined, resetExpiresAt: undefined }); + await saveAuthConfig({ passwordHash, resetTokenHash: undefined, resetExpiresAt: undefined }); send(res, 200, JSON.stringify({ ok: true })); } catch (error) { send(res, 400, JSON.stringify({ error: error.message })); @@ -665,7 +587,7 @@ async function updateAccount(req, res) { const payload = JSON.parse(await readBody(req) || '{}'); const email = String(payload.email || '').trim(); if (!/^\S+@\S+\.\S+$/.test(email)) return send(res, 400, JSON.stringify({ error: 'Enter a valid email address' })); - saveAuthConfig({ email, emailVerified: email === auth.email ? auth.emailVerified : false }); + await saveAuthConfig({ email, emailVerified: email === auth.email ? auth.emailVerified : false }); send(res, 200, JSON.stringify({ ok: true, email: auth.email, emailVerified: auth.emailVerified })); } catch (error) { send(res, 400, JSON.stringify({ error: error.message })); @@ -676,7 +598,7 @@ async function sendVerification(req, res) { try { if (!auth.email) return send(res, 400, JSON.stringify({ error: 'Set an account email first' })); const token = crypto.randomBytes(32).toString('base64url'); - saveAuthConfig({ verificationTokenHash: tokenHash(token), verificationExpiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() }); + await saveAuthConfig({ verificationTokenHash: tokenHash(token), verificationExpiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() }); const link = `${appOrigin(req)}/verify?token=${encodeURIComponent(token)}`; const text = `Use this link to verify your Calorie AI account email. It expires in 24 hours.\n\n${link}`; const html = actionEmailHtml('Verify your email', 'Please verify your Calorie AI account email by clicking the button below. This link expires in 24 hours.', link, 'Verify My Email'); @@ -692,7 +614,7 @@ async function verifyAccount(req, res) { const payload = JSON.parse(await readBody(req) || '{}'); const valid = auth.verificationTokenHash && auth.verificationExpiresAt && new Date(auth.verificationExpiresAt).getTime() > Date.now() && safeEqual(auth.verificationTokenHash, tokenHash(payload.token || '')); if (!valid) return send(res, 400, JSON.stringify({ error: 'Verification link is invalid or expired' })); - saveAuthConfig({ emailVerified: true, verificationTokenHash: undefined, verificationExpiresAt: undefined }); + await saveAuthConfig({ emailVerified: true, verificationTokenHash: undefined, verificationExpiresAt: undefined }); send(res, 200, JSON.stringify({ ok: true })); } catch (error) { send(res, 400, JSON.stringify({ error: error.message })); @@ -706,8 +628,12 @@ function logout(req, res) { } async function start() { + store = await createStore({ + dataDir, + legacyFiles: { authFile, modelsFile, plansFile, settingsFile, entriesFile }, + }); auth = await loadAuthConfig(); - modelConfig = loadModelConfig(); + modelConfig = await loadModelConfig(); const app = express(); app.use(express.text({ type: '*/*', limit: '12mb' })); app.use((req, res, next) => { diff --git a/web/server/storage.js b/web/server/storage.js new file mode 100644 index 0000000..5f93a44 --- /dev/null +++ b/web/server/storage.js @@ -0,0 +1,339 @@ +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() + ); + `); + + 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(); + } + + 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, + }; +} + +module.exports = { createStore, defaultSettings }; diff --git a/web/src/App.svelte b/web/src/App.svelte index 634aba9..16d880f 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -7,6 +7,7 @@ import DiaryPage from './DiaryPage.svelte'; import SettingsPage from './SettingsPage.svelte'; import PlansPage from './PlansPage.svelte'; + import { calculateCaloriePlan, planSummary } from './calorie.js'; const pages = [ { id: 'dashboard', label: 'Dashboard', icon: 'dashboard', group: 'Tracker' }, @@ -27,12 +28,20 @@ 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: '', }; let loginMode = location.pathname === '/login' || location.pathname === '/reset' || location.pathname === '/verify'; @@ -108,6 +117,9 @@ $: produceToday = today.fruit + today.vegetables; $: todayEntries = entries.filter(e => e.date === todayKey()).sort((a, b) => a.time.localeCompare(b.time)); $: 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) @@ -735,18 +747,38 @@ planStatus = 'Generating plan...'; planError = false; try { - if (!settings.heightCm || !settings.weightKg) throw new Error('Enter height and current weight first.'); + if (!settings.sex || !settings.age || !settings.heightCm || !settings.weightKg) throw new Error('Enter sex, age, height, and current weight first.'); + const calculatedPlan = calculateCaloriePlan(settings); + if (calculatedPlan && !settings.calorieTarget) settings.calorieTarget = String(calculatedPlan.goalCalories); await saveSettings(); const content = await chatCompletion({ model: settings.taskModel, temperature: 0.2, messages: [ - { role: 'system', content: 'Create safe, concise nutrition and weight-loss guidance. Do not diagnose disease. Recommend medical review for pregnancy, eating disorder history, chronic disease, or aggressive goals. Use plain text section titles and simple lines. Do not use markdown symbols.' }, - { role: 'user', content: `Create a personalized weight-loss plan using these details: height ${settings.heightCm} cm, current weight ${settings.weightKg} kg, target weight ${settings.targetWeightKg || 'not set'} kg, activity ${settings.activityLevel}, pace ${settings.pace}. Include daily calorie range, protein target, habits, weekly review steps, and safety notes. Recent meals: ${entries.slice(-10).map(entry => `${entry.date} ${entry.mealType}: ${entry.mealName || entry.description}, ${entry.calories || 0} kcal`).join('; ') || 'none logged yet'}.` }, + { role: 'system', content: 'Create safe, concise nutrition and weight-management guidance. Do not diagnose disease. Recommend medical review for pregnancy, eating disorder history, chronic disease, or aggressive goals. Use plain text section titles and simple lines. Do not use markdown symbols. Make it feel like a personalized plan built from onboarding answers, not a generic article.' }, + { role: 'user', content: `Create a personalized plan using these details: +Goal: ${settings.goal} +Sex: ${settings.sex} +Age: ${settings.age} +Height: ${settings.heightCm} cm +Current weight: ${settings.weightKg} kg +Target weight: ${settings.targetWeightKg || 'not set'} kg +Activity: ${settings.activityLevel} +Pace: ${settings.pace} +Calculated targets: ${planSummary(settings)} +Motivation: ${settings.motivation || 'not set'} +Biggest challenge: ${settings.challenge || 'not set'} +Tracking style: ${settings.trackingStyle || 'not set'} +Calorie-counting experience: ${settings.calorieCountingExperience || 'not set'} +Fasting interest: ${settings.fastingInterest || 'not set'} +Recent meals: ${entries.slice(-10).map(entry => `${entry.date} ${entry.mealType}: ${entry.mealName || entry.description}, ${entry.calories || 0} kcal`).join('; ') || 'none logged yet'}. + +Include: daily calorie target, maintenance calories, protein target, meal logging strategy, challenge-specific tactics, weekly review steps, and safety notes.` }, ], }); const version = Math.max(0, ...plans.map(p => Number(p.version || 0))) + 1; - const plan = { id: entryId(), title: 'Weight-loss plan', version, content, createdAt: new Date().toISOString() }; + const titlePrefix = settings.goal === 'Lose weight' ? 'Weight-loss' : settings.goal || 'Weight-management'; + const plan = { id: entryId(), title: `${titlePrefix} plan ${version}`, version, content, createdAt: new Date().toISOString() }; await savePlan(plan); planStatus = 'Plan generated.'; } catch (error) { @@ -778,7 +810,7 @@ ], }); const version = Math.max(0, ...plans.map(plan => Number(plan.version || 0))) + 1; - const updatedPlan = { id: entryId(), title: 'Weight-loss plan', version, content, createdAt: new Date().toISOString(), previousPlanId: currentPlan.id }; + const updatedPlan = { id: entryId(), title: `Updated plan ${version}`, version, content, createdAt: new Date().toISOString(), previousPlanId: currentPlan.id }; await savePlan(updatedPlan); tuneNote = ''; planStatus = 'Plan updated.'; @@ -965,6 +997,10 @@
Today
{today.calories} kcal
+ {#if activeCalorieTarget} +
+
{Math.max(0, activeCalorieTarget - today.calories)} kcal left of {activeCalorieTarget}
+ {/if}
P {today.protein}g  ·  C {today.carbs}g  ·  F {today.fat}g
Fruit {today.fruit.toFixed(1)}  ·  Veg {today.vegetables.toFixed(1)}
diff --git a/web/src/DiaryPage.svelte b/web/src/DiaryPage.svelte index ba5441e..735d8c1 100644 --- a/web/src/DiaryPage.svelte +++ b/web/src/DiaryPage.svelte @@ -82,10 +82,6 @@ {entry.mealName || entry.description} {entry.calories} kcal -
- - -
{entry.date} {entry.time} · {entry.mealType}
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)}
diff --git a/web/src/MealForm.svelte b/web/src/MealForm.svelte index cb37e2b..1324a64 100644 --- a/web/src/MealForm.svelte +++ b/web/src/MealForm.svelte @@ -36,13 +36,8 @@
-
diff --git a/web/src/PlansPage.svelte b/web/src/PlansPage.svelte index 95fe1fb..b8081bb 100644 --- a/web/src/PlansPage.svelte +++ b/web/src/PlansPage.svelte @@ -1,5 +1,6 @@
-

Your personal weight-loss plan, updated as you log meals and progress. Powered by AI, built from your profile.

+

Your plan is built from your body stats, goal, habits, challenges, and recent meals.

Profile

+ + + - {#if !settings.heightCm || !settings.weightKg} -

Fill in height and current weight to generate a plan.

+ + {#if caloriePlan} +
+
Calculated target
+
{planSummary(settings)}
+
Maintenance: {caloriePlan.maintenanceCalories} kcal/day · BMR: {caloriePlan.bmr} kcal/day
+
+ {:else} +

Fill in sex, age, height, and current weight to calculate a plan.

{/if} - + {#if planStatus}

{planStatus}

{/if}
+

Personalization

+
+ + + + + +
+
+ +

Current plan

{#if selectedPlan} diff --git a/web/src/calorie.js b/web/src/calorie.js new file mode 100644 index 0000000..3aaaa0e --- /dev/null +++ b/web/src/calorie.js @@ -0,0 +1,66 @@ +function asNumber(value) { + const number = Number(value); + return Number.isFinite(number) && number > 0 ? number : 0; +} + +function roundTo(value, step = 10) { + return Math.round(value / step) * step; +} + +function activityFactor(level) { + return { + Low: 1.2, + Moderate: 1.55, + High: 1.725, + }[level] || 1.375; +} + +function paceDelta(pace, goal) { + if (goal === 'Gain weight' || goal === 'Build muscle') return pace === 'Aggressive' ? 500 : pace === 'Steady' ? 350 : 250; + if (goal === 'Maintain weight') return 0; + return pace === 'Aggressive' ? -750 : pace === 'Steady' ? -500 : -250; +} + +function weeklyChangeKg(pace, goal) { + if (goal === 'Maintain weight') return 0; + const amount = pace === 'Aggressive' ? 0.75 : pace === 'Steady' ? 0.5 : 0.25; + return goal === 'Gain weight' || goal === 'Build muscle' ? amount : -amount; +} + +export function calculateCaloriePlan(settings) { + const weight = asNumber(settings.weightKg); + const height = asNumber(settings.heightCm); + const age = asNumber(settings.age); + if (!weight || !height || !age || !settings.sex) return null; + + const sexOffset = settings.sex === 'Male' ? 5 : -161; + const bmr = 10 * weight + 6.25 * height - 5 * age + sexOffset; + const maintenanceCalories = roundTo(bmr * activityFactor(settings.activityLevel)); + const goal = settings.goal || 'Lose weight'; + const goalCalories = Math.max(settings.sex === 'Male' ? 1500 : 1200, roundTo(maintenanceCalories + paceDelta(settings.pace, goal))); + const proteinTarget = roundTo(weight * (goal === 'Build muscle' ? 1.8 : goal === 'Maintain weight' ? 1.3 : 1.6), 5); + const weeklyKg = weeklyChangeKg(settings.pace, goal); + const targetWeight = asNumber(settings.targetWeightKg); + const remainingKg = targetWeight ? targetWeight - weight : 0; + const weeksToTarget = remainingKg && weeklyKg && Math.sign(remainingKg) === Math.sign(weeklyKg) + ? Math.ceil(Math.abs(remainingKg / weeklyKg)) + : 0; + + return { + bmr: roundTo(bmr), + maintenanceCalories, + goalCalories, + proteinTarget, + weeklyKg, + weeksToTarget, + }; +} + +export function planSummary(settings) { + const plan = calculateCaloriePlan(settings); + if (!plan) return 'Add sex, age, height, weight, and activity level to calculate a daily target.'; + const direction = plan.weeklyKg > 0 ? 'gain' : plan.weeklyKg < 0 ? 'lose' : 'maintain'; + const pace = plan.weeklyKg ? `${direction} about ${Math.abs(plan.weeklyKg).toFixed(2)} kg/week` : 'maintain weight'; + const weeks = plan.weeksToTarget ? ` Estimated time to target: ${plan.weeksToTarget} weeks.` : ''; + return `${plan.goalCalories} kcal/day, ${plan.proteinTarget} g protein/day, ${pace}.${weeks}`; +} diff --git a/web/tests/app.spec.js b/web/tests/app.spec.js index 7981007..86b6b64 100644 --- a/web/tests/app.spec.js +++ b/web/tests/app.spec.js @@ -174,9 +174,15 @@ test.describe('authenticated app', () => { await page.locator('nav').getByRole('button').filter({ hasText: 'Plans' }).click(); await expect(page.getByRole('heading', { name: 'Plans' })).toBeVisible(); + await page.getByLabel('Sex').selectOption('Male'); + await page.getByLabel('Age').fill('36'); await page.getByLabel('Height (cm)').fill('178'); await page.getByLabel('Current weight (kg)').fill('91'); await page.getByLabel('Target weight (kg)').fill('82'); + await page.getByLabel('Why this matters').selectOption('Improve my overall health'); + await page.getByLabel('Biggest challenge').selectOption('Being too busy'); + await page.getByLabel('Tracking style').selectOption('Photo log meals'); + await expect(page.getByText('Calculated target')).toBeVisible(); await page.getByRole('button', { name: 'Generate new plan' }).click(); await expect(page.getByText('Plan generated.')).toBeVisible(); await expect(page.getByText('Targets')).toBeVisible();