Add personalized plans with Postgres storage
This commit is contained in:
parent
f14b433085
commit
fcef0d48db
22 changed files with 862 additions and 154 deletions
|
|
@ -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
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -8,5 +8,6 @@ local.properties
|
|||
web/node_modules/
|
||||
web/test-results/
|
||||
web/playwright-report/
|
||||
web/.test-data/
|
||||
web/data/
|
||||
web/.env
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<MealEntry>, 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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
141
web/package-lock.json
generated
141
web/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
160
web/server.js
160
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) => {
|
||||
|
|
|
|||
339
web/server/storage.js
Normal file
339
web/server/storage.js
Normal file
|
|
@ -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 };
|
||||
|
|
@ -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 @@
|
|||
<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 · C {today.carbs}g · F {today.fat}g</div>
|
||||
<div id="todayProduceText" class="text-sm text-blue-200">Fruit {today.fruit.toFixed(1)} · Veg {today.vegetables.toFixed(1)}</div>
|
||||
</article>
|
||||
|
|
|
|||
|
|
@ -82,10 +82,6 @@
|
|||
<span class="font-semibold text-slate-800">{entry.mealName || entry.description}</span>
|
||||
<span class="ml-2 text-sm font-bold text-slate-600">{entry.calories} kcal</span>
|
||||
</div>
|
||||
<div class="flex shrink-0 gap-1">
|
||||
<button class="rounded px-2 py-0.5 text-xs font-semibold text-blue-600 hover:bg-blue-50" type="button" on:click={() => editEntry(entry)}>Edit</button>
|
||||
<button class="rounded px-2 py-0.5 text-xs font-semibold text-red-500 hover:bg-red-50" type="button" on:click={() => moveEntriesToTrash([entry.id])}>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm text-slate-500">{entry.date} {entry.time} · {entry.mealType}</div>
|
||||
<div class="mt-0.5 text-xs text-slate-400">P {entry.proteinGrams}g · C {entry.carbsGrams}g · F {entry.fatGrams}g · Fruit {Number(entry.fruitServings || 0).toFixed(1)} · Veg {Number(entry.vegetableServings || 0).toFixed(1)}</div>
|
||||
|
|
|
|||
|
|
@ -36,13 +36,8 @@
|
|||
<div class="flex gap-2">
|
||||
<label class="btn-secondary flex cursor-pointer items-center gap-2">
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path d="M2 6a2 2 0 012-2h.5l1.207-1.207A1 1 0 016.414 2.5h7.172a1 1 0 01.707.293L15.5 4H16a2 2 0 012 2v8a2 2 0 01-2 2H4a2 2 0 01-2-2V6zm8 1a3 3 0 100 6 3 3 0 000-6z"/></svg>
|
||||
Camera
|
||||
<input type="file" accept="image/*" capture="environment" class="sr-only" on:change={chooseImage}>
|
||||
</label>
|
||||
<label class="btn-secondary flex cursor-pointer items-center gap-2">
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path fill-rule="evenodd" d="M4 3a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V5a2 2 0 00-2-2H4zm12 12H4l4-8 3 6 2-4 3 6z" clip-rule="evenodd"/></svg>
|
||||
Gallery
|
||||
<input type="file" accept="image/*" class="sr-only" on:change={chooseImage}>
|
||||
Choose photo
|
||||
<input id="image" type="file" accept="image/*" class="sr-only" on:change={chooseImage}>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script>
|
||||
import Field from './Field.svelte';
|
||||
import { calculateCaloriePlan, planSummary } from './calorie.js';
|
||||
|
||||
export let settings;
|
||||
export let plans = [];
|
||||
|
|
@ -14,8 +15,17 @@
|
|||
export let deletePlan;
|
||||
|
||||
$: selectedPlan = plans.find(plan => plan.id === selectedPlanId) || plans[0];
|
||||
$: caloriePlan = calculateCaloriePlan(settings);
|
||||
let faqOpen = null;
|
||||
|
||||
const sexOptions = ['Female', 'Male'];
|
||||
const goalOptions = ['Lose weight', 'Maintain weight', 'Gain weight', 'Build muscle'];
|
||||
const motivationOptions = ['Improve my overall health', 'Feel more confident', 'Increase my fitness level', 'Prepare for an event', 'Improve my relationship with food'];
|
||||
const challengeOptions = ['Resisting cravings', 'Staying motivated', 'Reducing portion sizes', 'Knowing what to eat', 'Being too busy'];
|
||||
const trackingOptions = ['Photo log meals', 'Log before eating', 'Meal prep and plan ahead', 'Track calories closely', 'Build a streak'];
|
||||
const calorieExperienceOptions = ['New to calorie counting', 'Tried before', 'Experienced'];
|
||||
const fastingOptions = ['Interested', 'Tried it before', 'Not interested'];
|
||||
|
||||
const faqs = [
|
||||
{
|
||||
q: 'How does the AI build my plan?',
|
||||
|
|
@ -75,26 +85,47 @@
|
|||
</script>
|
||||
|
||||
<section class="space-y-4">
|
||||
<p class="text-sm text-slate-500">Your personal weight-loss plan, updated as you log meals and progress. Powered by AI, built from your profile.</p>
|
||||
<p class="text-sm text-slate-500">Your plan is built from your body stats, goal, habits, challenges, and recent meals.</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="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>
|
||||
{#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>
|
||||
<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-xl border border-blue-100 bg-blue-50 p-3 text-sm text-blue-950 md: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 text-amber-600 font-medium">Fill in sex, age, height, and current weight to calculate 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>
|
||||
<button class="btn-primary" type="button" on:click={generatePlan} disabled={planLoading || !caloriePlan}>{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>
|
||||
|
||||
<section class="card">
|
||||
<h3 class="card-title">Personalization</h3>
|
||||
<div class="grid gap-3 p-4 md:grid-cols-2 xl:grid-cols-1">
|
||||
<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 xl:col-span-2">
|
||||
<h3 class="card-title">Current plan</h3>
|
||||
<div class="grid gap-4 p-4">
|
||||
{#if selectedPlan}
|
||||
|
|
|
|||
66
web/src/calorie.js
Normal file
66
web/src/calorie.js
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
function asNumber(value) {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) && number > 0 ? number : 0;
|
||||
}
|
||||
|
||||
function roundTo(value, step = 10) {
|
||||
return Math.round(value / step) * step;
|
||||
}
|
||||
|
||||
function activityFactor(level) {
|
||||
return {
|
||||
Low: 1.2,
|
||||
Moderate: 1.55,
|
||||
High: 1.725,
|
||||
}[level] || 1.375;
|
||||
}
|
||||
|
||||
function paceDelta(pace, goal) {
|
||||
if (goal === 'Gain weight' || goal === 'Build muscle') return pace === 'Aggressive' ? 500 : pace === 'Steady' ? 350 : 250;
|
||||
if (goal === 'Maintain weight') return 0;
|
||||
return pace === 'Aggressive' ? -750 : pace === 'Steady' ? -500 : -250;
|
||||
}
|
||||
|
||||
function weeklyChangeKg(pace, goal) {
|
||||
if (goal === 'Maintain weight') return 0;
|
||||
const amount = pace === 'Aggressive' ? 0.75 : pace === 'Steady' ? 0.5 : 0.25;
|
||||
return goal === 'Gain weight' || goal === 'Build muscle' ? amount : -amount;
|
||||
}
|
||||
|
||||
export function calculateCaloriePlan(settings) {
|
||||
const weight = asNumber(settings.weightKg);
|
||||
const height = asNumber(settings.heightCm);
|
||||
const age = asNumber(settings.age);
|
||||
if (!weight || !height || !age || !settings.sex) return null;
|
||||
|
||||
const sexOffset = settings.sex === 'Male' ? 5 : -161;
|
||||
const bmr = 10 * weight + 6.25 * height - 5 * age + sexOffset;
|
||||
const maintenanceCalories = roundTo(bmr * activityFactor(settings.activityLevel));
|
||||
const goal = settings.goal || 'Lose weight';
|
||||
const goalCalories = Math.max(settings.sex === 'Male' ? 1500 : 1200, roundTo(maintenanceCalories + paceDelta(settings.pace, goal)));
|
||||
const proteinTarget = roundTo(weight * (goal === 'Build muscle' ? 1.8 : goal === 'Maintain weight' ? 1.3 : 1.6), 5);
|
||||
const weeklyKg = weeklyChangeKg(settings.pace, goal);
|
||||
const targetWeight = asNumber(settings.targetWeightKg);
|
||||
const remainingKg = targetWeight ? targetWeight - weight : 0;
|
||||
const weeksToTarget = remainingKg && weeklyKg && Math.sign(remainingKg) === Math.sign(weeklyKg)
|
||||
? Math.ceil(Math.abs(remainingKg / weeklyKg))
|
||||
: 0;
|
||||
|
||||
return {
|
||||
bmr: roundTo(bmr),
|
||||
maintenanceCalories,
|
||||
goalCalories,
|
||||
proteinTarget,
|
||||
weeklyKg,
|
||||
weeksToTarget,
|
||||
};
|
||||
}
|
||||
|
||||
export function planSummary(settings) {
|
||||
const plan = calculateCaloriePlan(settings);
|
||||
if (!plan) return 'Add sex, age, height, weight, and activity level to calculate a daily target.';
|
||||
const direction = plan.weeklyKg > 0 ? 'gain' : plan.weeklyKg < 0 ? 'lose' : 'maintain';
|
||||
const pace = plan.weeklyKg ? `${direction} about ${Math.abs(plan.weeklyKg).toFixed(2)} kg/week` : 'maintain weight';
|
||||
const weeks = plan.weeksToTarget ? ` Estimated time to target: ${plan.weeksToTarget} weeks.` : '';
|
||||
return `${plan.goalCalories} kcal/day, ${plan.proteinTarget} g protein/day, ${pace}.${weeks}`;
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue