Add Health Connect activity sync
This commit is contained in:
parent
fcef0d48db
commit
f73b58a158
15 changed files with 469 additions and 20 deletions
22
README.md
22
README.md
|
|
@ -8,8 +8,8 @@ Native Android and Dockerized web calorie tracker with meal images, AI nutrition
|
||||||
- Analyze meals through OpenAI-compatible chat completions.
|
- Analyze meals through OpenAI-compatible chat completions.
|
||||||
- Uses a vision model for food image calorie and portion estimates.
|
- Uses a vision model for food image calorie and portion estimates.
|
||||||
- Uses an advice model to normalize calories, protein, carbs, fat, fruit servings, vegetable servings, food groups, and notes.
|
- Uses an advice model to normalize calories, protein, carbs, fat, fruit servings, vegetable servings, food groups, and notes.
|
||||||
- Stores daily meal entries locally on Android or in browser local storage.
|
- Stores meal entries, settings, plans, and synced activity in PostgreSQL.
|
||||||
- Stores generated web weight-loss plans on the server in `web/data/plans.json`.
|
- Imports Garmin Connect and gym app activity through Android Health Connect.
|
||||||
- Shows daily totals plus charts for macros, 7-day calories, and fruit/vegetable intake.
|
- Shows daily totals plus charts for macros, 7-day calories, and fruit/vegetable intake.
|
||||||
- Admin settings control API base URL, API key, image model, and nutrition/advice model. The two model fields can contain the same model name.
|
- Admin settings control API base URL, API key, image model, and nutrition/advice model. The two model fields can contain the same model name.
|
||||||
|
|
||||||
|
|
@ -48,6 +48,16 @@ Recommended Obtainium setup:
|
||||||
|
|
||||||
The APK is a debug build, so Android may require allowing installs from Obtainium and accepting the debug signing certificate.
|
The APK is a debug build, so Android may require allowing installs from Obtainium and accepting the debug signing certificate.
|
||||||
|
|
||||||
|
## Garmin And Gym Activity
|
||||||
|
|
||||||
|
Calorie AI imports external activity through Android Health Connect:
|
||||||
|
|
||||||
|
- Garmin: enable Health Connect sync in Garmin Connect, then open Calorie AI on Android and use `Settings -> Sync Health Connect`.
|
||||||
|
- Gym machines: use the machine's companion app or gym app to write workouts to Health Connect, then sync from Calorie AI.
|
||||||
|
- Imported data includes daily steps, active calories, and exercise sessions when Health Connect exposes them.
|
||||||
|
|
||||||
|
Direct Garmin Health API sync requires Garmin partner approval and API credentials, so the supported path is Health Connect first.
|
||||||
|
|
||||||
## Web Frontend
|
## Web Frontend
|
||||||
|
|
||||||
The web frontend lives in `web/`. It serves an authenticated browser UI and a tiny Node proxy at `/api/chat` so OpenAI-compatible endpoints are called server-side instead of directly from the browser.
|
The web frontend lives in `web/`. It serves an authenticated browser UI and a tiny Node proxy at `/api/chat` so OpenAI-compatible endpoints are called server-side instead of directly from the browser.
|
||||||
|
|
@ -65,7 +75,9 @@ Then open:
|
||||||
http://127.0.0.1:8095
|
http://127.0.0.1:8095
|
||||||
```
|
```
|
||||||
|
|
||||||
The Docker web server creates credentials on first boot in `web/data/auth.json` if `CALORIE_AI_WEB_PASSWORD` and `CALORIE_AI_SESSION_SECRET` are not supplied. To pin credentials, copy `web/.env.example` to `web/.env` and set strong values before starting Docker Compose.
|
The Docker web stack includes PostgreSQL. Runtime data lives under `web/data/postgres`, and legacy JSON/SQLite data in `web/data` is migrated on startup.
|
||||||
|
|
||||||
|
The Docker web server creates credentials on first boot if `CALORIE_AI_WEB_PASSWORD` and `CALORIE_AI_SESSION_SECRET` are not supplied. To pin credentials, copy `web/.env.example` to `web/.env` and set strong values before starting Docker Compose.
|
||||||
|
|
||||||
## AI Endpoint
|
## AI Endpoint
|
||||||
|
|
||||||
|
|
@ -80,6 +92,4 @@ Example base URLs:
|
||||||
- `https://api.openai.com/v1`
|
- `https://api.openai.com/v1`
|
||||||
- An emulator host-loopback URL ending in `:11434/v1` for a local OpenAI-compatible service
|
- An emulator host-loopback URL ending in `:11434/v1` for a local OpenAI-compatible service
|
||||||
|
|
||||||
Default admin PIN is `admin`. Change it in the Admin AI Settings panel after first launch.
|
The web and Android clients use the same authenticated server APIs for entries, settings, plans, models, and synced activity.
|
||||||
|
|
||||||
The web frontend stores AI settings in browser local storage after web login, while generated plan versions are stored server-side. The web login is separate from the Android admin PIN.
|
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ dependencies {
|
||||||
implementation 'androidx.compose.material:material-icons-extended'
|
implementation 'androidx.compose.material:material-icons-extended'
|
||||||
implementation 'androidx.compose.ui:ui'
|
implementation 'androidx.compose.ui:ui'
|
||||||
implementation 'androidx.compose.ui:ui-tooling-preview'
|
implementation 'androidx.compose.ui:ui-tooling-preview'
|
||||||
|
implementation 'androidx.health.connect:connect-client:1.1.0-alpha12'
|
||||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0'
|
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0'
|
||||||
debugImplementation 'androidx.compose.ui:ui-tooling'
|
debugImplementation 'androidx.compose.ui:ui-tooling'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.health.READ_STEPS" />
|
||||||
|
<uses-permission android:name="android.permission.health.READ_ACTIVE_CALORIES_BURNED" />
|
||||||
|
<uses-permission android:name="android.permission.health.READ_EXERCISE" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,15 @@ import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.health.connect.client.contracts.HealthPermissionsRequestContract
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
import com.danvics.calorieai.ai.ImagePayload
|
import com.danvics.calorieai.ai.ImagePayload
|
||||||
import com.danvics.calorieai.ai.NutritionParser
|
import com.danvics.calorieai.ai.NutritionParser
|
||||||
import com.danvics.calorieai.data.*
|
import com.danvics.calorieai.data.*
|
||||||
|
import com.danvics.calorieai.integrations.HealthConnectAvailability
|
||||||
|
import com.danvics.calorieai.integrations.HealthConnectSync
|
||||||
import com.danvics.calorieai.ui.CalorieAiApp
|
import com.danvics.calorieai.ui.CalorieAiApp
|
||||||
import com.danvics.calorieai.ui.CalorieTheme
|
import com.danvics.calorieai.ui.CalorieTheme
|
||||||
import com.danvics.calorieai.ui.ConnectScreen
|
import com.danvics.calorieai.ui.ConnectScreen
|
||||||
|
|
@ -41,6 +44,7 @@ private fun AppRoot(repo: ApiRepository) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val parser = remember { NutritionParser() }
|
val parser = remember { NutritionParser() }
|
||||||
|
val healthConnect = remember { HealthConnectSync(context) }
|
||||||
|
|
||||||
val cameraImageFile = remember { File(context.cacheDir, "cam_capture.jpg") }
|
val cameraImageFile = remember { File(context.cacheDir, "cam_capture.jpg") }
|
||||||
val cameraImageUri: Uri = remember {
|
val cameraImageUri: Uri = remember {
|
||||||
|
|
@ -60,8 +64,33 @@ private fun AppRoot(repo: ApiRepository) {
|
||||||
var planStatus by remember { mutableStateOf("") }
|
var planStatus by remember { mutableStateOf("") }
|
||||||
var planBusy by remember { mutableStateOf(false) }
|
var planBusy by remember { mutableStateOf(false) }
|
||||||
var syncing by remember { mutableStateOf(false) }
|
var syncing by remember { mutableStateOf(false) }
|
||||||
|
var healthConnectStatus by remember { mutableStateOf("Health Connect not synced.") }
|
||||||
var mealSaveCount by remember { mutableStateOf(0) }
|
var mealSaveCount by remember { mutableStateOf(0) }
|
||||||
|
|
||||||
|
fun performHealthConnectSync() {
|
||||||
|
healthConnectStatus = "Syncing Health Connect..."
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
val activities = healthConnect.readActivities()
|
||||||
|
repo.syncActivities(activities)
|
||||||
|
}.onSuccess { activities ->
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
appState = appState.copy(activities = activities)
|
||||||
|
healthConnectStatus = "Synced ${activities.size} Health Connect record${if (activities.size == 1) "" else "s"}."
|
||||||
|
}
|
||||||
|
}.onFailure { e ->
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
if (e is UnauthorizedException) {
|
||||||
|
connected = false
|
||||||
|
appState = AppState()
|
||||||
|
} else {
|
||||||
|
healthConnectStatus = "Health Connect sync failed: ${e.message}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val imagePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
|
val imagePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
|
||||||
if (uri != null) {
|
if (uri != null) {
|
||||||
scope.launch(Dispatchers.IO) {
|
scope.launch(Dispatchers.IO) {
|
||||||
|
|
@ -98,8 +127,33 @@ private fun AppRoot(repo: ApiRepository) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val healthPermissionLauncher = rememberLauncherForActivityResult(HealthPermissionsRequestContract()) { granted ->
|
||||||
|
if (granted.containsAll(healthConnect.permissions)) performHealthConnectSync()
|
||||||
|
else healthConnectStatus = "Health Connect permission was not granted."
|
||||||
|
}
|
||||||
|
|
||||||
fun handleUnauth() { connected = false; appState = AppState() }
|
fun handleUnauth() { connected = false; appState = AppState() }
|
||||||
|
|
||||||
|
fun syncHealthConnect() {
|
||||||
|
when (healthConnect.availability()) {
|
||||||
|
HealthConnectAvailability.Unavailable -> {
|
||||||
|
healthConnectStatus = "Health Connect is not available on this device."
|
||||||
|
}
|
||||||
|
HealthConnectAvailability.UpdateRequired -> {
|
||||||
|
healthConnectStatus = "Install or update Health Connect, then try again."
|
||||||
|
}
|
||||||
|
HealthConnectAvailability.Available -> {
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
val granted = runCatching { healthConnect.hasPermissions() }.getOrDefault(false)
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
if (granted) performHealthConnectSync()
|
||||||
|
else healthPermissionLauncher.launch(healthConnect.permissions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun syncState() {
|
fun syncState() {
|
||||||
syncing = true
|
syncing = true
|
||||||
scope.launch(Dispatchers.IO) {
|
scope.launch(Dispatchers.IO) {
|
||||||
|
|
@ -257,7 +311,8 @@ private fun AppRoot(repo: ApiRepository) {
|
||||||
val recentMeals = appState.entries.takeLast(10)
|
val recentMeals = appState.entries.takeLast(10)
|
||||||
.joinToString("; ") { "${it.date} ${it.mealType}: ${it.estimate.mealName}, ${it.estimate.calories} kcal" }
|
.joinToString("; ") { "${it.date} ${it.mealType}: ${it.estimate.mealName}, ${it.estimate.calories} kcal" }
|
||||||
.ifBlank { "none logged yet" }
|
.ifBlank { "none logged yet" }
|
||||||
val content = repo.chat(buildPlanBody(planSettings.taskModel, planSettings, recentMeals))
|
val recentActivity = recentActivitySummary(appState.activities, 10)
|
||||||
|
val content = repo.chat(buildPlanBody(planSettings.taskModel, planSettings, recentMeals, recentActivity))
|
||||||
val plan = Plan(
|
val plan = Plan(
|
||||||
id = UUID.randomUUID().toString(),
|
id = UUID.randomUUID().toString(),
|
||||||
title = "Plan ${appState.plans.plans.size + 1}",
|
title = "Plan ${appState.plans.plans.size + 1}",
|
||||||
|
|
@ -286,7 +341,8 @@ private fun AppRoot(repo: ApiRepository) {
|
||||||
val recentMeals = appState.entries.takeLast(15)
|
val recentMeals = appState.entries.takeLast(15)
|
||||||
.joinToString("; ") { "${it.date} ${it.mealType}: ${it.estimate.mealName}, ${it.estimate.calories} kcal" }
|
.joinToString("; ") { "${it.date} ${it.mealType}: ${it.estimate.mealName}, ${it.estimate.calories} kcal" }
|
||||||
.ifBlank { "none logged yet" }
|
.ifBlank { "none logged yet" }
|
||||||
val content = repo.chat(buildTuneBody(appState.settings.taskModel, currentPlan.content, note, recentMeals))
|
val recentActivity = recentActivitySummary(appState.activities, 15)
|
||||||
|
val content = repo.chat(buildTuneBody(appState.settings.taskModel, currentPlan.content, note, recentMeals, recentActivity))
|
||||||
val version = appState.plans.plans.maxOfOrNull { it.version }?.plus(1) ?: 2
|
val version = appState.plans.plans.maxOfOrNull { it.version }?.plus(1) ?: 2
|
||||||
val updated = Plan(
|
val updated = Plan(
|
||||||
id = UUID.randomUUID().toString(),
|
id = UUID.randomUUID().toString(),
|
||||||
|
|
@ -322,6 +378,8 @@ private fun AppRoot(repo: ApiRepository) {
|
||||||
.onFailure { e -> withContext(Dispatchers.Main) { if (e is UnauthorizedException) handleUnauth() } }
|
.onFailure { e -> withContext(Dispatchers.Main) { if (e is UnauthorizedException) handleUnauth() } }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
healthConnectStatus = healthConnectStatus,
|
||||||
|
onHealthConnectSync = { syncHealthConnect() },
|
||||||
onDisconnect = {
|
onDisconnect = {
|
||||||
repo.disconnect()
|
repo.disconnect()
|
||||||
connected = false
|
connected = false
|
||||||
|
|
@ -361,6 +419,12 @@ private fun buildTodayHistory(entries: List<MealEntry>, date: String): String {
|
||||||
return "$rows\nTotal so far: ${totals[0]} kcal, P${totals[1]}g C${totals[2]}g F${totals[3]}g"
|
return "$rows\nTotal so far: ${totals[0]} kcal, P${totals[1]}g C${totals[2]}g F${totals[3]}g"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun recentActivitySummary(activities: List<ActivityRecord>, limit: Int): String =
|
||||||
|
activities.take(limit).joinToString("; ") {
|
||||||
|
val steps = if (it.steps > 0) ", ${it.steps} steps" else ""
|
||||||
|
"${it.startAt.take(10)} ${it.title}: ${it.calories.toInt()} active kcal$steps (${it.sourceName})"
|
||||||
|
}.ifBlank { "none synced yet" }
|
||||||
|
|
||||||
private fun buildNutritionBody(model: String, description: String, measure: String, visionEstimate: String, todayHistory: String): JSONObject =
|
private fun buildNutritionBody(model: String, description: String, measure: String, visionEstimate: String, todayHistory: String): JSONObject =
|
||||||
JSONObject().put("model", model).put("temperature", 0.1)
|
JSONObject().put("model", model).put("temperature", 0.1)
|
||||||
.put("messages", JSONArray()
|
.put("messages", JSONArray()
|
||||||
|
|
@ -375,7 +439,7 @@ private fun buildNutritionBody(model: String, description: String, measure: Stri
|
||||||
"Estimate nutrition for this meal.\nDescription: $description\nPortion: $measure\nImage analysis: $visionEstimate\n\nUser's meals logged today:\n$todayHistory"
|
"Estimate nutrition for this meal.\nDescription: $description\nPortion: $measure\nImage analysis: $visionEstimate\n\nUser's meals logged today:\n$todayHistory"
|
||||||
)))
|
)))
|
||||||
|
|
||||||
private fun buildPlanBody(model: String, settings: ServerSettings, recentMeals: String): JSONObject =
|
private fun buildPlanBody(model: String, settings: ServerSettings, recentMeals: String, recentActivity: String): JSONObject =
|
||||||
JSONObject().put("model", model).put("temperature", 0.2)
|
JSONObject().put("model", model).put("temperature", 0.2)
|
||||||
.put("messages", JSONArray()
|
.put("messages", JSONArray()
|
||||||
.put(JSONObject().put("role", "system").put("content", "Create safe, concise weight-management guidance. Do not diagnose disease. Use plain text section titles. Do not use markdown symbols. Recommend medical review for pregnancy, eating disorders, chronic disease, or aggressive goals. Make it feel like a personalized plan built from onboarding answers, not a generic article."))
|
.put(JSONObject().put("role", "system").put("content", "Create safe, concise weight-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."))
|
||||||
|
|
@ -395,14 +459,15 @@ private fun buildPlanBody(model: String, settings: ServerSettings, recentMeals:
|
||||||
"Tracking style: ${settings.trackingStyle.ifBlank { "not set" }}\n" +
|
"Tracking style: ${settings.trackingStyle.ifBlank { "not set" }}\n" +
|
||||||
"Calorie-counting experience: ${settings.calorieCountingExperience.ifBlank { "not set" }}\n" +
|
"Calorie-counting experience: ${settings.calorieCountingExperience.ifBlank { "not set" }}\n" +
|
||||||
"Fasting interest: ${settings.fastingInterest.ifBlank { "not set" }}\n" +
|
"Fasting interest: ${settings.fastingInterest.ifBlank { "not set" }}\n" +
|
||||||
"Recent meals: $recentMeals\n\n" +
|
"Recent meals: $recentMeals\n" +
|
||||||
"Include: daily calorie target, maintenance calories, protein target, meal logging strategy, challenge-specific tactics, weekly review steps, and safety notes."
|
"Recent activity: $recentActivity\n\n" +
|
||||||
|
"Include: daily calorie target, maintenance calories, protein target, meal logging strategy, activity adjustment rules, challenge-specific tactics, weekly review steps, and safety notes."
|
||||||
)))
|
)))
|
||||||
|
|
||||||
private fun buildTuneBody(model: String, currentContent: String, note: String, recentMeals: String): JSONObject =
|
private fun buildTuneBody(model: String, currentContent: String, note: String, recentMeals: String, recentActivity: String): JSONObject =
|
||||||
JSONObject().put("model", model).put("temperature", 0.2)
|
JSONObject().put("model", model).put("temperature", 0.2)
|
||||||
.put("messages", JSONArray()
|
.put("messages", JSONArray()
|
||||||
.put(JSONObject().put("role", "system").put("content", "Update this weight-loss plan using the notes and recent meals. Use plain text section titles. Do not use markdown symbols."))
|
.put(JSONObject().put("role", "system").put("content", "Update this weight-loss plan using the notes and recent meals. Use plain text section titles. Do not use markdown symbols."))
|
||||||
.put(JSONObject().put("role", "user").put("content",
|
.put(JSONObject().put("role", "user").put("content",
|
||||||
"Current plan:\n$currentContent\n\nUpdate notes:\n$note\n\nRecent meals:\n$recentMeals"
|
"Current plan:\n$currentContent\n\nUpdate notes:\n$note\n\nRecent meals:\n$recentMeals\n\nRecent activity:\n$recentActivity"
|
||||||
)))
|
)))
|
||||||
|
|
|
||||||
|
|
@ -43,12 +43,14 @@ class ApiRepository(private val prefs: SharedPreferences) {
|
||||||
|
|
||||||
fun getAppState(): AppState {
|
fun getAppState(): AppState {
|
||||||
val entries = JSONObject(get("/api/entries"))
|
val entries = JSONObject(get("/api/entries"))
|
||||||
|
val activities = JSONObject(get("/api/activities"))
|
||||||
val settings = parseSettings(JSONObject(get("/api/settings")))
|
val settings = parseSettings(JSONObject(get("/api/settings")))
|
||||||
val plans = parsePlans(JSONObject(get("/api/plans")))
|
val plans = parsePlans(JSONObject(get("/api/plans")))
|
||||||
val models = runCatching { parseModels(JSONObject(post("/api/models", "{}"))) }.getOrDefault(emptyList())
|
val models = runCatching { parseModels(JSONObject(post("/api/models", "{}"))) }.getOrDefault(emptyList())
|
||||||
return AppState(
|
return AppState(
|
||||||
entries = parseEntries(entries.optJSONArray("entries")),
|
entries = parseEntries(entries.optJSONArray("entries")),
|
||||||
trash = parseEntries(entries.optJSONArray("trash")),
|
trash = parseEntries(entries.optJSONArray("trash")),
|
||||||
|
activities = parseActivities(activities.optJSONArray("activities")),
|
||||||
plans = plans,
|
plans = plans,
|
||||||
settings = settings,
|
settings = settings,
|
||||||
models = models
|
models = models
|
||||||
|
|
@ -109,6 +111,14 @@ class ApiRepository(private val prefs: SharedPreferences) {
|
||||||
fun deletePlan(id: String): PlansState =
|
fun deletePlan(id: String): PlansState =
|
||||||
parsePlans(JSONObject(post("/api/plans/delete", JSONObject().put("id", id).toString())))
|
parsePlans(JSONObject(post("/api/plans/delete", JSONObject().put("id", id).toString())))
|
||||||
|
|
||||||
|
fun syncActivities(activities: List<ActivityRecord>): List<ActivityRecord> {
|
||||||
|
val body = JSONObject()
|
||||||
|
.put("source", "health_connect")
|
||||||
|
.put("activities", JSONArray(activities.map { it.toJson() }))
|
||||||
|
.toString()
|
||||||
|
return parseActivities(JSONObject(post("/api/activities/sync", body)).optJSONArray("activities"))
|
||||||
|
}
|
||||||
|
|
||||||
fun chat(body: JSONObject): String {
|
fun chat(body: JSONObject): String {
|
||||||
val response = JSONObject(post("/api/chat", JSONObject().put("body", body).toString()))
|
val response = JSONObject(post("/api/chat", JSONObject().put("body", body).toString()))
|
||||||
return response.getJSONArray("choices").getJSONObject(0).getJSONObject("message").getString("content").trim()
|
return response.getJSONArray("choices").getJSONObject(0).getJSONObject("message").getString("content").trim()
|
||||||
|
|
@ -147,6 +157,9 @@ class ApiRepository(private val prefs: SharedPreferences) {
|
||||||
private fun parseEntries(arr: JSONArray?): List<MealEntry> =
|
private fun parseEntries(arr: JSONArray?): List<MealEntry> =
|
||||||
(0 until (arr?.length() ?: 0)).mapNotNull { arr?.optJSONObject(it)?.toMealEntry() }
|
(0 until (arr?.length() ?: 0)).mapNotNull { arr?.optJSONObject(it)?.toMealEntry() }
|
||||||
|
|
||||||
|
private fun parseActivities(arr: JSONArray?): List<ActivityRecord> =
|
||||||
|
(0 until (arr?.length() ?: 0)).mapNotNull { arr?.optJSONObject(it)?.toActivityRecord() }
|
||||||
|
|
||||||
fun clearTrash(): Pair<List<MealEntry>, List<MealEntry>> {
|
fun clearTrash(): Pair<List<MealEntry>, List<MealEntry>> {
|
||||||
val root = JSONObject(post("/api/entries/clear-trash", "{}"))
|
val root = JSONObject(post("/api/entries/clear-trash", "{}"))
|
||||||
return parseEntries(root.optJSONArray("entries")) to parseEntries(root.optJSONArray("trash"))
|
return parseEntries(root.optJSONArray("entries")) to parseEntries(root.optJSONArray("trash"))
|
||||||
|
|
@ -229,3 +242,30 @@ fun JSONObject.toMealEntry(): MealEntry = MealEntry(
|
||||||
foodGroups = optString("foodGroups"), notes = optString("notes"), raw = optString("rawAi")
|
foodGroups = optString("foodGroups"), notes = optString("notes"), raw = optString("rawAi")
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fun ActivityRecord.toJson(): JSONObject = JSONObject()
|
||||||
|
.put("id", id)
|
||||||
|
.put("source", source)
|
||||||
|
.put("sourceName", sourceName)
|
||||||
|
.put("type", type)
|
||||||
|
.put("title", title)
|
||||||
|
.put("startAt", startAt)
|
||||||
|
.put("endAt", endAt)
|
||||||
|
.put("calories", calories)
|
||||||
|
.put("steps", steps)
|
||||||
|
.put("distanceMeters", distanceMeters)
|
||||||
|
.put("syncedAt", syncedAt)
|
||||||
|
|
||||||
|
fun JSONObject.toActivityRecord(): ActivityRecord = ActivityRecord(
|
||||||
|
id = optString("id"),
|
||||||
|
source = optString("source", "external"),
|
||||||
|
sourceName = optString("sourceName", optString("source", "External")),
|
||||||
|
type = optString("type", "Activity"),
|
||||||
|
title = optString("title", optString("type", "Activity")),
|
||||||
|
startAt = optString("startAt"),
|
||||||
|
endAt = optString("endAt"),
|
||||||
|
calories = optDouble("calories"),
|
||||||
|
steps = optLong("steps"),
|
||||||
|
distanceMeters = optDouble("distanceMeters"),
|
||||||
|
syncedAt = optString("syncedAt")
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,20 @@ data class DayTotals(
|
||||||
|
|
||||||
data class ModelOption(val id: String, val name: String = id)
|
data class ModelOption(val id: String, val name: String = id)
|
||||||
|
|
||||||
|
data class ActivityRecord(
|
||||||
|
val id: String,
|
||||||
|
val source: String,
|
||||||
|
val sourceName: String,
|
||||||
|
val type: String,
|
||||||
|
val title: String,
|
||||||
|
val startAt: String,
|
||||||
|
val endAt: String,
|
||||||
|
val calories: Double = 0.0,
|
||||||
|
val steps: Long = 0,
|
||||||
|
val distanceMeters: Double = 0.0,
|
||||||
|
val syncedAt: String = ""
|
||||||
|
)
|
||||||
|
|
||||||
data class LocalConfig(val serverUrl: String = "", val sessionCookie: String = "") {
|
data class LocalConfig(val serverUrl: String = "", val sessionCookie: String = "") {
|
||||||
fun isConnected() = serverUrl.isNotBlank() && sessionCookie.isNotBlank()
|
fun isConnected() = serverUrl.isNotBlank() && sessionCookie.isNotBlank()
|
||||||
}
|
}
|
||||||
|
|
@ -81,6 +95,7 @@ data class ServerSettings(
|
||||||
data class AppState(
|
data class AppState(
|
||||||
val entries: List<MealEntry> = emptyList(),
|
val entries: List<MealEntry> = emptyList(),
|
||||||
val trash: List<MealEntry> = emptyList(),
|
val trash: List<MealEntry> = emptyList(),
|
||||||
|
val activities: List<ActivityRecord> = emptyList(),
|
||||||
val plans: PlansState = PlansState(),
|
val plans: PlansState = PlansState(),
|
||||||
val settings: ServerSettings = ServerSettings(),
|
val settings: ServerSettings = ServerSettings(),
|
||||||
val models: List<ModelOption> = emptyList()
|
val models: List<ModelOption> = emptyList()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
package com.danvics.calorieai.integrations
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.health.connect.client.HealthConnectClient
|
||||||
|
import androidx.health.connect.client.permission.HealthPermission
|
||||||
|
import androidx.health.connect.client.records.ActiveCaloriesBurnedRecord
|
||||||
|
import androidx.health.connect.client.records.ExerciseSessionRecord
|
||||||
|
import androidx.health.connect.client.records.StepsRecord
|
||||||
|
import androidx.health.connect.client.request.AggregateRequest
|
||||||
|
import androidx.health.connect.client.request.ReadRecordsRequest
|
||||||
|
import androidx.health.connect.client.time.TimeRangeFilter
|
||||||
|
import com.danvics.calorieai.data.ActivityRecord
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.ZoneId
|
||||||
|
|
||||||
|
class HealthConnectSync(private val context: Context) {
|
||||||
|
val permissions = setOf(
|
||||||
|
HealthPermission.getReadPermission(StepsRecord::class),
|
||||||
|
HealthPermission.getReadPermission(ActiveCaloriesBurnedRecord::class),
|
||||||
|
HealthPermission.getReadPermission(ExerciseSessionRecord::class),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun availability(): HealthConnectAvailability = when (HealthConnectClient.getSdkStatus(context)) {
|
||||||
|
HealthConnectClient.SDK_AVAILABLE -> HealthConnectAvailability.Available
|
||||||
|
HealthConnectClient.SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED -> HealthConnectAvailability.UpdateRequired
|
||||||
|
else -> HealthConnectAvailability.Unavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun hasPermissions(): Boolean {
|
||||||
|
if (availability() != HealthConnectAvailability.Available) return false
|
||||||
|
val granted = HealthConnectClient.getOrCreate(context).permissionController.getGrantedPermissions()
|
||||||
|
return granted.containsAll(permissions)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun readActivities(days: Long = 14): List<ActivityRecord> {
|
||||||
|
if (!hasPermissions()) return emptyList()
|
||||||
|
val client = HealthConnectClient.getOrCreate(context)
|
||||||
|
val zone = ZoneId.systemDefault()
|
||||||
|
val today = LocalDate.now(zone)
|
||||||
|
val syncedAt = Instant.now().toString()
|
||||||
|
val daily = (0 until days).mapNotNull { offset ->
|
||||||
|
val date = today.minusDays(offset)
|
||||||
|
val start = date.atStartOfDay(zone).toInstant()
|
||||||
|
val end = date.plusDays(1).atStartOfDay(zone).toInstant()
|
||||||
|
val aggregate = client.aggregate(
|
||||||
|
AggregateRequest(
|
||||||
|
metrics = setOf(StepsRecord.COUNT_TOTAL, ActiveCaloriesBurnedRecord.ACTIVE_CALORIES_TOTAL),
|
||||||
|
timeRangeFilter = TimeRangeFilter.between(start, end),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val steps = aggregate[StepsRecord.COUNT_TOTAL] ?: 0L
|
||||||
|
val calories = aggregate[ActiveCaloriesBurnedRecord.ACTIVE_CALORIES_TOTAL]?.inKilocalories ?: 0.0
|
||||||
|
if (steps == 0L && calories <= 0.0) null else ActivityRecord(
|
||||||
|
id = "health-connect-day-${date}",
|
||||||
|
source = "health_connect",
|
||||||
|
sourceName = "Health Connect",
|
||||||
|
type = "Daily activity",
|
||||||
|
title = "Daily activity",
|
||||||
|
startAt = start.toString(),
|
||||||
|
endAt = end.toString(),
|
||||||
|
calories = calories,
|
||||||
|
steps = steps,
|
||||||
|
syncedAt = syncedAt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val rangeStart = today.minusDays(days - 1).atStartOfDay(zone).toInstant()
|
||||||
|
val rangeEnd = today.plusDays(1).atStartOfDay(zone).toInstant()
|
||||||
|
val sessions = client.readRecords(
|
||||||
|
ReadRecordsRequest(
|
||||||
|
recordType = ExerciseSessionRecord::class,
|
||||||
|
timeRangeFilter = TimeRangeFilter.between(rangeStart, rangeEnd),
|
||||||
|
pageSize = 200,
|
||||||
|
)
|
||||||
|
).records.map { session ->
|
||||||
|
val source = session.metadata.dataOrigin.packageName
|
||||||
|
ActivityRecord(
|
||||||
|
id = "health-connect-session-${session.metadata.id}",
|
||||||
|
source = "health_connect",
|
||||||
|
sourceName = source.ifBlank { "Health Connect" },
|
||||||
|
type = "Exercise ${session.exerciseType}",
|
||||||
|
title = session.title ?: "Exercise session",
|
||||||
|
startAt = session.startTime.toString(),
|
||||||
|
endAt = session.endTime.toString(),
|
||||||
|
syncedAt = syncedAt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (daily + sessions).sortedByDescending { it.startAt }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class HealthConnectAvailability {
|
||||||
|
Available,
|
||||||
|
UpdateRequired,
|
||||||
|
Unavailable,
|
||||||
|
}
|
||||||
|
|
@ -50,6 +50,8 @@ fun CalorieAiApp(
|
||||||
onTunePlan: (String, String) -> Unit,
|
onTunePlan: (String, String) -> Unit,
|
||||||
onSelectPlan: (String) -> Unit,
|
onSelectPlan: (String) -> Unit,
|
||||||
onDeletePlan: (String) -> Unit,
|
onDeletePlan: (String) -> Unit,
|
||||||
|
healthConnectStatus: String,
|
||||||
|
onHealthConnectSync: () -> Unit,
|
||||||
onDisconnect: () -> Unit
|
onDisconnect: () -> Unit
|
||||||
) {
|
) {
|
||||||
CalorieTheme {
|
CalorieTheme {
|
||||||
|
|
@ -95,7 +97,7 @@ fun CalorieAiApp(
|
||||||
) { padding ->
|
) { padding ->
|
||||||
Box(Modifier.padding(padding).fillMaxSize()) {
|
Box(Modifier.padding(padding).fillMaxSize()) {
|
||||||
when (screen) {
|
when (screen) {
|
||||||
Screen.Dashboard -> DashboardScreen(appState.entries, appState.settings, onLog = { screen = Screen.Log })
|
Screen.Dashboard -> DashboardScreen(appState.entries, appState.activities, appState.settings, onLog = { screen = Screen.Log })
|
||||||
Screen.Log -> LogMealScreen(
|
Screen.Log -> LogMealScreen(
|
||||||
editing = editing,
|
editing = editing,
|
||||||
selectedImageName = selectedImageName,
|
selectedImageName = selectedImageName,
|
||||||
|
|
@ -117,7 +119,7 @@ fun CalorieAiApp(
|
||||||
onClearTrash = onClearTrash
|
onClearTrash = onClearTrash
|
||||||
)
|
)
|
||||||
Screen.Plans -> PlansScreen(appState.settings, appState.plans, planStatus, planBusy, onGeneratePlan, onTunePlan, onSelectPlan, onDeletePlan)
|
Screen.Plans -> PlansScreen(appState.settings, appState.plans, planStatus, planBusy, onGeneratePlan, onTunePlan, onSelectPlan, onDeletePlan)
|
||||||
Screen.Settings -> SettingsScreen(appState.settings, serverUrl, onSettingsChange, onDisconnect)
|
Screen.Settings -> SettingsScreen(appState.settings, serverUrl, healthConnectStatus, onSettingsChange, onHealthConnectSync, onDisconnect)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.danvics.calorieai.data.ActivityRecord
|
||||||
import com.danvics.calorieai.data.MealEntry
|
import com.danvics.calorieai.data.MealEntry
|
||||||
import com.danvics.calorieai.data.MealStats
|
import com.danvics.calorieai.data.MealStats
|
||||||
import com.danvics.calorieai.data.ServerSettings
|
import com.danvics.calorieai.data.ServerSettings
|
||||||
|
|
@ -13,8 +14,11 @@ import com.danvics.calorieai.data.calculateCalorieTarget
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DashboardScreen(entries: List<MealEntry>, settings: ServerSettings, onLog: () -> Unit) = Page {
|
fun DashboardScreen(entries: List<MealEntry>, activities: List<ActivityRecord>, settings: ServerSettings, onLog: () -> Unit) = Page {
|
||||||
val today = MealStats.totalsForDate(entries, LocalDate.now().toString())
|
val today = MealStats.totalsForDate(entries, LocalDate.now().toString())
|
||||||
|
val todayActivities = activities.filter { it.startAt.take(10) == LocalDate.now().toString() }
|
||||||
|
val activityCalories = todayActivities.sumOf { it.calories }.toInt()
|
||||||
|
val activitySteps = todayActivities.sumOf { it.steps }
|
||||||
val week = MealStats.lastSevenDays(entries)
|
val week = MealStats.lastSevenDays(entries)
|
||||||
val avgCalories = if (week.isEmpty()) 0 else week.sumOf { it.calories } / week.size
|
val avgCalories = if (week.isEmpty()) 0 else week.sumOf { it.calories } / week.size
|
||||||
val macroTotal = today.protein + today.carbs + today.fat
|
val macroTotal = today.protein + today.carbs + today.fat
|
||||||
|
|
@ -37,6 +41,11 @@ fun DashboardScreen(entries: List<MealEntry>, settings: ServerSettings, onLog: (
|
||||||
StatTile("7-day avg", "$avgCalories kcal", Modifier.weight(1f))
|
StatTile("7-day avg", "$avgCalories kcal", Modifier.weight(1f))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
|
||||||
|
StatTile("Activity", "$activityCalories kcal", Modifier.weight(1f))
|
||||||
|
StatTile("Steps", activitySteps.toString(), Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
|
||||||
if (calorieTarget > 0) {
|
if (calorieTarget > 0) {
|
||||||
SectionCard("Daily calorie goal") {
|
SectionCard("Daily calorie goal") {
|
||||||
CalorieGoalBar(today.calories, calorieTarget)
|
CalorieGoalBar(today.calories, calorieTarget)
|
||||||
|
|
@ -77,4 +86,17 @@ fun DashboardScreen(entries: List<MealEntry>, settings: ServerSettings, onLog: (
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SectionCard("Synced activity") {
|
||||||
|
if (todayActivities.isEmpty()) {
|
||||||
|
Text("No activity synced yet. Connect Garmin or gym apps through Health Connect.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
} else {
|
||||||
|
todayActivities.take(5).forEach { activity ->
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||||
|
Text(activity.title, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
|
||||||
|
Text("${activity.calories.toInt()} kcal", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,9 @@ private val FASTING_INTEREST = listOf("Interested", "Tried it before", "Not inte
|
||||||
fun SettingsScreen(
|
fun SettingsScreen(
|
||||||
settings: ServerSettings,
|
settings: ServerSettings,
|
||||||
serverUrl: String,
|
serverUrl: String,
|
||||||
|
healthConnectStatus: String,
|
||||||
onSettingsChange: (ServerSettings) -> Unit,
|
onSettingsChange: (ServerSettings) -> Unit,
|
||||||
|
onHealthConnectSync: () -> Unit,
|
||||||
onDisconnect: () -> Unit
|
onDisconnect: () -> Unit
|
||||||
) = Page {
|
) = Page {
|
||||||
var draft by remember(settings) { mutableStateOf(settings) }
|
var draft by remember(settings) { mutableStateOf(settings) }
|
||||||
|
|
@ -72,6 +74,16 @@ fun SettingsScreen(
|
||||||
DropdownField("Fasting interest", FASTING_INTEREST, draft.fastingInterest, { draft = draft.copy(fastingInterest = it) })
|
DropdownField("Fasting interest", FASTING_INTEREST, draft.fastingInterest, { draft = draft.copy(fastingInterest = it) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SectionCard("Garmin and gym machines") {
|
||||||
|
Text(
|
||||||
|
"Sync Garmin Connect, supported gym equipment apps, and other wearables through Android Health Connect.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Button(onClick = onHealthConnectSync, modifier = Modifier.fillMaxWidth()) { Text("Sync Health Connect") }
|
||||||
|
Text(healthConnectStatus, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
|
||||||
SectionCard("Server") {
|
SectionCard("Server") {
|
||||||
Text("Connected to $serverUrl", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
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)
|
Text("AI models and API keys are configured on the server.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
|
|
||||||
|
|
@ -307,6 +307,20 @@ async function clearTrashRoute(req, res) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getActivitiesRoute(req, res) {
|
||||||
|
send(res, 200, JSON.stringify(await store.getActivities()));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncActivitiesRoute(req, res) {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(await readBody(req) || '{}');
|
||||||
|
const activities = Array.isArray(payload.activities) ? payload.activities : [];
|
||||||
|
send(res, 200, JSON.stringify(await store.upsertActivities(activities, payload.source || '')));
|
||||||
|
} catch (error) {
|
||||||
|
send(res, 400, JSON.stringify({ error: error.message }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function dedupeModels(models) {
|
function dedupeModels(models) {
|
||||||
return Array.from(new Map((models || []).filter(model => model && model.id).map(model => [model.id, {
|
return Array.from(new Map((models || []).filter(model => model && model.id).map(model => [model.id, {
|
||||||
id: String(model.id),
|
id: String(model.id),
|
||||||
|
|
@ -672,6 +686,8 @@ async function start() {
|
||||||
app.post('/api/entries/delete', wrap(deleteEntriesRoute));
|
app.post('/api/entries/delete', wrap(deleteEntriesRoute));
|
||||||
app.post('/api/entries/restore', wrap(restoreEntryRoute));
|
app.post('/api/entries/restore', wrap(restoreEntryRoute));
|
||||||
app.post('/api/entries/clear-trash', wrap(clearTrashRoute));
|
app.post('/api/entries/clear-trash', wrap(clearTrashRoute));
|
||||||
|
app.get('/api/activities', wrap(getActivitiesRoute));
|
||||||
|
app.post('/api/activities/sync', wrap(syncActivitiesRoute));
|
||||||
app.post('/api/chat', wrap(proxyChat));
|
app.post('/api/chat', wrap(proxyChat));
|
||||||
app.use((req, res) => {
|
app.use((req, res) => {
|
||||||
if (req.method === 'GET' || req.method === 'HEAD') return serveStatic(req, res);
|
if (req.method === 'GET' || req.method === 'HEAD') return serveStatic(req, res);
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,21 @@ async function createStore({
|
||||||
trashed_at TEXT,
|
trashed_at TEXT,
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
);
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS activities (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
source_name TEXT NOT NULL DEFAULT '',
|
||||||
|
type TEXT NOT NULL DEFAULT '',
|
||||||
|
title TEXT NOT NULL DEFAULT '',
|
||||||
|
start_at TEXT NOT NULL,
|
||||||
|
end_at TEXT NOT NULL DEFAULT '',
|
||||||
|
calories DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||||
|
steps BIGINT NOT NULL DEFAULT 0,
|
||||||
|
distance_meters DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||||
|
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
synced_at TEXT NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
async function getKey(key, fallback) {
|
async function getKey(key, fallback) {
|
||||||
|
|
@ -313,6 +328,87 @@ async function createStore({
|
||||||
return getEntries();
|
return getEntries();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeActivity(activity, fallbackSource = '') {
|
||||||
|
const startAt = activity.startAt || activity.date || nowIso();
|
||||||
|
const source = String(activity.source || fallbackSource || 'external');
|
||||||
|
return {
|
||||||
|
id: String(activity.id || `${source}-${startAt}`),
|
||||||
|
source,
|
||||||
|
sourceName: String(activity.sourceName || activity.source_name || source),
|
||||||
|
type: String(activity.type || 'Activity'),
|
||||||
|
title: String(activity.title || activity.type || 'Activity'),
|
||||||
|
startAt: String(startAt),
|
||||||
|
endAt: String(activity.endAt || activity.end_at || startAt),
|
||||||
|
calories: Number(activity.calories || 0),
|
||||||
|
steps: Number(activity.steps || 0),
|
||||||
|
distanceMeters: Number(activity.distanceMeters || activity.distance_meters || 0),
|
||||||
|
metadata: activity.metadata && typeof activity.metadata === 'object' ? activity.metadata : {},
|
||||||
|
syncedAt: String(activity.syncedAt || nowIso()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getActivities(limit = 500) {
|
||||||
|
const result = await pool.query(`
|
||||||
|
SELECT id, source, source_name, type, title, start_at, end_at, calories, steps, distance_meters, metadata, synced_at
|
||||||
|
FROM activities
|
||||||
|
ORDER BY start_at DESC
|
||||||
|
LIMIT $1
|
||||||
|
`, [limit]);
|
||||||
|
return {
|
||||||
|
activities: result.rows.map(row => ({
|
||||||
|
id: row.id,
|
||||||
|
source: row.source,
|
||||||
|
sourceName: row.source_name,
|
||||||
|
type: row.type,
|
||||||
|
title: row.title,
|
||||||
|
startAt: row.start_at,
|
||||||
|
endAt: row.end_at,
|
||||||
|
calories: Number(row.calories || 0),
|
||||||
|
steps: Number(row.steps || 0),
|
||||||
|
distanceMeters: Number(row.distance_meters || 0),
|
||||||
|
metadata: row.metadata || {},
|
||||||
|
syncedAt: row.synced_at,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertActivities(activities, source = '') {
|
||||||
|
for (const item of activities) {
|
||||||
|
const activity = normalizeActivity(item, source);
|
||||||
|
await pool.query(`
|
||||||
|
INSERT INTO activities (id, source, source_name, type, title, start_at, end_at, calories, steps, distance_meters, metadata, synced_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12, now())
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
source = excluded.source,
|
||||||
|
source_name = excluded.source_name,
|
||||||
|
type = excluded.type,
|
||||||
|
title = excluded.title,
|
||||||
|
start_at = excluded.start_at,
|
||||||
|
end_at = excluded.end_at,
|
||||||
|
calories = excluded.calories,
|
||||||
|
steps = excluded.steps,
|
||||||
|
distance_meters = excluded.distance_meters,
|
||||||
|
metadata = excluded.metadata,
|
||||||
|
synced_at = excluded.synced_at,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
`, [
|
||||||
|
activity.id,
|
||||||
|
activity.source,
|
||||||
|
activity.sourceName,
|
||||||
|
activity.type,
|
||||||
|
activity.title,
|
||||||
|
activity.startAt,
|
||||||
|
activity.endAt,
|
||||||
|
activity.calories,
|
||||||
|
activity.steps,
|
||||||
|
activity.distanceMeters,
|
||||||
|
JSON.stringify(activity.metadata),
|
||||||
|
activity.syncedAt,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return getActivities();
|
||||||
|
}
|
||||||
|
|
||||||
await migrateLegacy();
|
await migrateLegacy();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -333,6 +429,8 @@ async function createStore({
|
||||||
moveEntriesToTrash,
|
moveEntriesToTrash,
|
||||||
restoreEntry,
|
restoreEntry,
|
||||||
clearTrash,
|
clearTrash,
|
||||||
|
getActivities,
|
||||||
|
upsertActivities,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,7 @@
|
||||||
let sidebarCollapsed = false;
|
let sidebarCollapsed = false;
|
||||||
|
|
||||||
let entries = [];
|
let entries = [];
|
||||||
|
let activities = [];
|
||||||
let models = fallbackModels;
|
let models = fallbackModels;
|
||||||
let discoveredModels = [];
|
let discoveredModels = [];
|
||||||
let modelSearch = '';
|
let modelSearch = '';
|
||||||
|
|
@ -116,6 +117,11 @@
|
||||||
$: weekAverage = Math.round(week.reduce((sum, day) => sum + day.calories, 0) / week.length);
|
$: weekAverage = Math.round(week.reduce((sum, day) => sum + day.calories, 0) / week.length);
|
||||||
$: produceToday = today.fruit + today.vegetables;
|
$: produceToday = today.fruit + today.vegetables;
|
||||||
$: todayEntries = entries.filter(e => e.date === todayKey()).sort((a, b) => a.time.localeCompare(b.time));
|
$: todayEntries = entries.filter(e => e.date === todayKey()).sort((a, b) => a.time.localeCompare(b.time));
|
||||||
|
$: todayActivities = activities.filter(activity => String(activity.startAt || '').slice(0, 10) === todayKey());
|
||||||
|
$: activityToday = todayActivities.reduce((sum, activity) => ({
|
||||||
|
calories: sum.calories + Number(activity.calories || 0),
|
||||||
|
steps: sum.steps + Number(activity.steps || 0),
|
||||||
|
}), { calories: 0, steps: 0 });
|
||||||
$: macroTotal = Math.max(1, today.protein + today.carbs + today.fat);
|
$: macroTotal = Math.max(1, today.protein + today.carbs + today.fat);
|
||||||
$: calculatedCaloriePlan = calculateCaloriePlan(settings);
|
$: calculatedCaloriePlan = calculateCaloriePlan(settings);
|
||||||
$: activeCalorieTarget = Number(settings.calorieTarget) || calculatedCaloriePlan?.goalCalories || 0;
|
$: activeCalorieTarget = Number(settings.calorieTarget) || calculatedCaloriePlan?.goalCalories || 0;
|
||||||
|
|
@ -136,7 +142,7 @@
|
||||||
if (location.pathname === '/verify' && resetToken) await verifyAccountToken();
|
if (location.pathname === '/verify' && resetToken) await verifyAccountToken();
|
||||||
page = localStorage.getItem('calorie_ai_last_tab') || 'dashboard';
|
page = localStorage.getItem('calorie_ai_last_tab') || 'dashboard';
|
||||||
setDefaultMealDateTime();
|
setDefaultMealDateTime();
|
||||||
await Promise.all([loadSettingsFromServer(), loadEntriesFromServer(), loadModels(), loadPlans()]);
|
await Promise.all([loadSettingsFromServer(), loadEntriesFromServer(), loadActivitiesFromServer(), loadModels(), loadPlans()]);
|
||||||
await drawCharts();
|
await drawCharts();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -302,6 +308,13 @@
|
||||||
trash = data.trash || [];
|
trash = data.trash || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadActivitiesFromServer() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/activities');
|
||||||
|
if (response.ok) activities = (await response.json()).activities || [];
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
function applyPlanConfig(config) {
|
function applyPlanConfig(config) {
|
||||||
plans = config.plans || [];
|
plans = config.plans || [];
|
||||||
selectedPlanId = config.selectedPlanId || plans[0]?.id || '';
|
selectedPlanId = config.selectedPlanId || plans[0]?.id || '';
|
||||||
|
|
@ -547,6 +560,17 @@
|
||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function recentActivitySummary(limit = 10) {
|
||||||
|
const rows = activities.slice(0, limit).map(activity => {
|
||||||
|
const label = activity.title || activity.type || 'Activity';
|
||||||
|
const date = String(activity.startAt || '').slice(0, 10);
|
||||||
|
const calories = Math.round(Number(activity.calories || 0));
|
||||||
|
const steps = Number(activity.steps || 0);
|
||||||
|
return `${date} ${label}: ${calories} active kcal${steps ? `, ${steps} steps` : ''} (${activity.sourceName || activity.source || 'external'})`;
|
||||||
|
});
|
||||||
|
return rows.join('; ') || 'none synced yet';
|
||||||
|
}
|
||||||
|
|
||||||
async function requestMealEstimate(imageEstimate) {
|
async function requestMealEstimate(imageEstimate) {
|
||||||
const todayHistory = buildTodayHistory();
|
const todayHistory = buildTodayHistory();
|
||||||
const userContent = [
|
const userContent = [
|
||||||
|
|
@ -772,8 +796,9 @@ Tracking style: ${settings.trackingStyle || 'not set'}
|
||||||
Calorie-counting experience: ${settings.calorieCountingExperience || 'not set'}
|
Calorie-counting experience: ${settings.calorieCountingExperience || 'not set'}
|
||||||
Fasting interest: ${settings.fastingInterest || 'not set'}
|
Fasting interest: ${settings.fastingInterest || 'not set'}
|
||||||
Recent meals: ${entries.slice(-10).map(entry => `${entry.date} ${entry.mealType}: ${entry.mealName || entry.description}, ${entry.calories || 0} kcal`).join('; ') || 'none logged yet'}.
|
Recent meals: ${entries.slice(-10).map(entry => `${entry.date} ${entry.mealType}: ${entry.mealName || entry.description}, ${entry.calories || 0} kcal`).join('; ') || 'none logged yet'}.
|
||||||
|
Recent activity: ${recentActivitySummary()}.
|
||||||
|
|
||||||
Include: daily calorie target, maintenance calories, protein target, meal logging strategy, challenge-specific tactics, weekly review steps, and safety notes.` },
|
Include: daily calorie target, maintenance calories, protein target, meal logging strategy, activity adjustment rules, challenge-specific tactics, weekly review steps, and safety notes.` },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
const version = Math.max(0, ...plans.map(p => Number(p.version || 0))) + 1;
|
const version = Math.max(0, ...plans.map(p => Number(p.version || 0))) + 1;
|
||||||
|
|
@ -806,7 +831,7 @@ Include: daily calorie target, maintenance calories, protein target, meal loggin
|
||||||
temperature: 0.2,
|
temperature: 0.2,
|
||||||
messages: [
|
messages: [
|
||||||
{ role: 'system', content: 'Update the weight-loss plan using the provided notes and logged meals. Use plain text section titles and simple lines. Do not use markdown symbols. Do not mention chat history.' },
|
{ role: 'system', content: 'Update the weight-loss plan using the provided notes and logged meals. Use plain text section titles and simple lines. Do not use markdown symbols. Do not mention chat history.' },
|
||||||
{ role: 'user', content: `Current plan:\n${currentPlan.content}\n\nUpdate note:\n${tuneNote}\n\nRecent meals:\n${entries.slice(-15).map(entry => `${entry.date} ${entry.mealType}: ${entry.mealName || entry.description}, ${entry.calories || 0} kcal`).join('; ') || 'none logged yet'}` },
|
{ role: 'user', content: `Current plan:\n${currentPlan.content}\n\nUpdate note:\n${tuneNote}\n\nRecent meals:\n${entries.slice(-15).map(entry => `${entry.date} ${entry.mealType}: ${entry.mealName || entry.description}, ${entry.calories || 0} kcal`).join('; ') || 'none logged yet'}\n\nRecent activity:\n${recentActivitySummary(15)}` },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
const version = Math.max(0, ...plans.map(plan => Number(plan.version || 0))) + 1;
|
const version = Math.max(0, ...plans.map(plan => Number(plan.version || 0))) + 1;
|
||||||
|
|
@ -993,7 +1018,7 @@ Include: daily calorie target, maintenance calories, protein target, meal loggin
|
||||||
<main class="min-w-0 flex-1 p-4 lg:p-6">
|
<main class="min-w-0 flex-1 p-4 lg:p-6">
|
||||||
{#if page === 'dashboard'}
|
{#if page === 'dashboard'}
|
||||||
<section class="space-y-4">
|
<section class="space-y-4">
|
||||||
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
|
||||||
<article class="sm:col-span-2 rounded-2xl bg-gradient-to-br from-blue-600 to-indigo-700 p-5 text-white shadow-sm">
|
<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 class="text-xs font-semibold uppercase tracking-widest text-blue-200">Today</div>
|
||||||
<div id="todayCalories" class="mt-1 text-4xl font-black tracking-tight">{today.calories} kcal</div>
|
<div id="todayCalories" class="mt-1 text-4xl font-black tracking-tight">{today.calories} kcal</div>
|
||||||
|
|
@ -1006,6 +1031,7 @@ Include: daily calorie target, maintenance calories, protein target, meal loggin
|
||||||
</article>
|
</article>
|
||||||
<Stat label="Meals today" value={today.meals} note={today.meals === 1 ? 'meal logged' : 'meals logged'} />
|
<Stat label="Meals today" value={today.meals} note={today.meals === 1 ? 'meal logged' : 'meals logged'} />
|
||||||
<Stat label="7-day average" value={`${weekAverage} kcal`} note="Calories per day" />
|
<Stat label="7-day average" value={`${weekAverage} kcal`} note="Calories per day" />
|
||||||
|
<Stat label="Activity today" value={`${Math.round(activityToday.calories)} kcal`} note={`${activityToday.steps} steps`} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-4 lg:grid-cols-2">
|
<div class="grid gap-4 lg:grid-cols-2">
|
||||||
|
|
@ -1052,6 +1078,25 @@ Include: daily calorie target, maintenance calories, protein target, meal loggin
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
<section class="card lg:col-span-2">
|
||||||
|
<h3 class="card-title">Synced activity</h3>
|
||||||
|
<div class="divide-y divide-slate-100">
|
||||||
|
{#each todayActivities.slice(0, 5) as activity}
|
||||||
|
<div class="flex items-center justify-between px-4 py-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="truncate text-sm font-semibold text-slate-800">{activity.title || activity.type || 'Activity'}</div>
|
||||||
|
<div class="text-xs text-slate-400">{activity.sourceName || activity.source || 'External'} · {String(activity.startAt || '').slice(11, 16) || 'All day'}</div>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3 shrink-0 text-right text-sm font-bold text-slate-600">
|
||||||
|
{Math.round(Number(activity.calories || 0))} kcal
|
||||||
|
{#if activity.steps}<div class="text-xs font-medium text-slate-400">{activity.steps} steps</div>{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="px-4 py-8 text-center text-sm text-slate-400">No activity synced yet. Connect Garmin or gym apps through Health Connect on Android.</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -78,5 +78,14 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="card xl:col-span-2">
|
||||||
|
<h3 class="card-title">Wearables and gym machines</h3>
|
||||||
|
<div class="grid gap-3 p-4 text-sm leading-6 text-slate-600 md:grid-cols-3">
|
||||||
|
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3"><strong class="block text-slate-900">Garmin</strong>Sync Garmin Connect to Android Health Connect, then tap Sync Health Connect in the Android app.</div>
|
||||||
|
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3"><strong class="block text-slate-900">Gym machines</strong>Use the machine or gym app to write workouts to Health Connect. Calorie AI imports the workout sessions and daily active calories.</div>
|
||||||
|
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3"><strong class="block text-slate-900">Planning</strong>Synced active calories and steps are saved on the server and included when generating or updating plans.</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,18 @@ async function mockEntries(page) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function mockActivities(page) {
|
||||||
|
let activities = [];
|
||||||
|
await page.route('**/api/activities', async (route) => {
|
||||||
|
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ activities }) });
|
||||||
|
});
|
||||||
|
await page.route('**/api/activities/sync', async (route) => {
|
||||||
|
const body = route.request().postDataJSON();
|
||||||
|
activities = [...(body.activities || []), ...activities];
|
||||||
|
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ activities }) });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function mockSettings(page) {
|
async function mockSettings(page) {
|
||||||
let settings = {};
|
let settings = {};
|
||||||
await page.route('**/api/settings', async (route) => {
|
await page.route('**/api/settings', async (route) => {
|
||||||
|
|
@ -122,6 +134,7 @@ async function login(page) {
|
||||||
await mockModels(page);
|
await mockModels(page);
|
||||||
await mockPlans(page);
|
await mockPlans(page);
|
||||||
await mockEntries(page);
|
await mockEntries(page);
|
||||||
|
await mockActivities(page);
|
||||||
await mockSettings(page);
|
await mockSettings(page);
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await expect(page).toHaveURL(/\/login$/);
|
await expect(page).toHaveURL(/\/login$/);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue