Compare commits

..

5 commits
v1.6 ... main

Author SHA1 Message Date
Daniel
dd70da8c71 Add Capacitor Health Connect sync
All checks were successful
Android CI / build (push) Successful in 2m6s
Web CI / test (push) Successful in 1m14s
Android Release / release-apk (push) Successful in 1m52s
2026-05-25 16:31:51 +02:00
Daniel
38d9f50c31 Fix Calorie AI release publishing
All checks were successful
Android CI / build (push) Successful in 2m19s
Web CI / test (push) Successful in 1m12s
Android Release / release-apk (push) Successful in 1m45s
2026-05-25 04:11:00 +02:00
Daniel
ef6b3a494c Use Node 22 in Calorie AI workflows
Some checks failed
Android CI / build (push) Successful in 2m26s
Web CI / test (push) Successful in 1m14s
Android Release / release-apk (push) Failing after 1m36s
2026-05-25 04:03:47 +02:00
Daniel
176a9fc3bb Make calorie logging reliable
Some checks failed
Android CI / build (push) Successful in 3m0s
Web CI / test (push) Successful in 1m31s
Android Release / release-apk (push) Failing after 45s
2026-05-25 03:55:59 +02:00
Daniel
33d50258ce Improve plan UX and split web UI components
Some checks failed
Android CI / build (push) Successful in 1m52s
Web CI / test (push) Successful in 54s
Android Release / release-apk (push) Failing after 21s
2026-05-21 03:40:47 +02:00
19 changed files with 1022 additions and 470 deletions

View file

@ -21,7 +21,11 @@ jobs:
- name: Install tools
run: |
apt-get update
apt-get install -y --no-install-recommends ca-certificates curl nodejs npm unzip
apt-get install -y --no-install-recommends ca-certificates curl unzip
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt-get install -y --no-install-recommends nodejs
node --version
npm --version
- name: Build web app and sync Capacitor
env:
@ -53,7 +57,7 @@ jobs:
release_json="$(node -e 'console.log(JSON.stringify({ tag_name: process.env.GITHUB_REF_NAME, name: `Calorie AI ${process.env.GITHUB_REF_NAME}`, body: "Capacitor Android APK for Calorie AI.", draft: false, prerelease: false }))' | curl -fsS -X POST -H "$auth_header" -H 'Content-Type: application/json' --data-binary @- "$FORGEJO_API/repos/${GITHUB_REPOSITORY}/releases")"
fi
release_id="$(printf '%s' "$release_json" | node -e 'let data=""; process.stdin.on("data", c => data += c); process.stdin.on("end", () => console.log(JSON.parse(data).id));')"
asset_id="$(printf '%s' "$release_json" | node -e 'let data=""; process.stdin.on("data", c => data += c); process.stdin.on("end", () => { const release = JSON.parse(data); const asset = (release.assets || []).find(item => item.name === \`calorie-ai-${process.env.GITHUB_REF_NAME}.apk\`); if (asset) console.log(asset.id); });')"
asset_id="$(printf '%s' "$release_json" | node -e 'let data=""; process.stdin.on("data", c => data += c); process.stdin.on("end", () => { const release = JSON.parse(data); const expected = "calorie-ai-" + process.env.GITHUB_REF_NAME + ".apk"; const asset = (release.assets || []).find(item => item.name === expected); if (asset) console.log(asset.id); });')"
if [ -n "$asset_id" ]; then
curl -fsS -X DELETE -H "$auth_header" "$FORGEJO_API/repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets/${asset_id}"
fi

View file

@ -19,7 +19,11 @@ jobs:
- name: Install Node and browsers
run: |
apt-get update
apt-get install -y --no-install-recommends ca-certificates curl nodejs npm postgresql postgresql-client
apt-get install -y --no-install-recommends ca-certificates curl postgresql postgresql-client
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt-get install -y --no-install-recommends nodejs
node --version
npm --version
service postgresql start
su - postgres -c "psql -tc \"SELECT 1 FROM pg_roles WHERE rolname='calorie_ai'\" | grep -q 1 || psql -c \"CREATE USER calorie_ai WITH PASSWORD 'calorie_ai';\""
su - postgres -c "psql -tc \"SELECT 1 FROM pg_database WHERE datname='calorie_ai_test'\" | grep -q 1 || createdb -O calorie_ai calorie_ai_test"

View file

@ -12,8 +12,8 @@ android {
applicationId 'com.danvics.calorieai'
minSdk 26
targetSdk 35
versionCode 4
versionName '1.3'
versionCode 11
versionName '1.11'
}
buildFeatures {

View file

@ -1,4 +1,5 @@
apply plugin: 'com.android.application'
apply plugin: 'org.jetbrains.kotlin.android'
android {
namespace = "com.danvics.calorieai"
@ -7,8 +8,8 @@ android {
applicationId "com.danvics.calorieai"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0"
versionCode 11
versionName "1.11"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@ -22,6 +23,13 @@ android {
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
kotlinOptions {
jvmTarget = '21'
}
}
repositories {
@ -35,6 +43,8 @@ dependencies {
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation 'androidx.health.connect:connect-client:1.1.0-alpha12'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0'
implementation project(':capacitor-android')
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"

View file

@ -24,6 +24,17 @@
</activity>
<activity-alias
android:name=".ViewPermissionUsageActivity"
android:exported="true"
android:permission="android.permission.START_VIEW_PERMISSION_USAGE"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE" />
<category android:name="android.intent.category.HEALTH_PERMISSIONS" />
</intent-filter>
</activity-alias>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
@ -41,4 +52,7 @@
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="androidx.health.permission.READ_STEPS" />
<uses-permission android:name="androidx.health.permission.READ_ACTIVE_CALORIES_BURNED" />
<uses-permission android:name="androidx.health.permission.READ_EXERCISE" />
</manifest>

View file

@ -0,0 +1,171 @@
package com.danvics.calorieai
import androidx.activity.result.ActivityResultLauncher
import androidx.health.connect.client.HealthConnectClient
import androidx.health.connect.client.permission.HealthPermission
import androidx.health.connect.client.records.ActiveCaloriesBurnedRecord
import androidx.health.connect.client.records.ExerciseSessionRecord
import androidx.health.connect.client.records.StepsRecord
import androidx.health.connect.client.records.metadata.DataOrigin
import androidx.health.connect.client.request.AggregateRequest
import androidx.health.connect.client.request.ReadRecordsRequest
import androidx.health.connect.client.time.TimeRangeFilter
import androidx.health.connect.client.PermissionController
import com.getcapacitor.JSArray
import com.getcapacitor.JSObject
import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
@CapacitorPlugin(name = "HealthConnect")
class HealthConnectPlugin : Plugin() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private val permissions = setOf(
HealthPermission.getReadPermission(StepsRecord::class),
HealthPermission.getReadPermission(ActiveCaloriesBurnedRecord::class),
HealthPermission.getReadPermission(ExerciseSessionRecord::class),
)
private var permissionLauncher: ActivityResultLauncher<Set<String>>? = null
private var pendingSyncCall: PluginCall? = null
override fun load() {
permissionLauncher = activity.registerForActivityResult(
PermissionController.createRequestPermissionResultContract()
) { granted ->
val call = pendingSyncCall ?: return@registerForActivityResult
pendingSyncCall = null
if (granted.containsAll(permissions)) readAndResolve(call) else call.reject("Health Connect permission was not granted.")
}
}
@PluginMethod
fun sync(call: PluginCall) {
when (HealthConnectClient.getSdkStatus(context)) {
HealthConnectClient.SDK_AVAILABLE -> ensurePermissionsThenSync(call)
HealthConnectClient.SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED -> call.reject("Install or update Health Connect, then try again.")
else -> call.reject("Health Connect is not available on this device.")
}
}
private fun ensurePermissionsThenSync(call: PluginCall) {
scope.launch {
runCatching {
val granted = HealthConnectClient.getOrCreate(context).permissionController.getGrantedPermissions()
if (granted.containsAll(permissions)) {
readActivities(call)
} else {
pendingSyncCall = call
permissionLauncher?.launch(permissions) ?: call.reject("Health Connect permissions are not ready.")
}
}.onFailure { error -> call.reject(error.message ?: "Health Connect sync failed.") }
}
}
private fun readAndResolve(call: PluginCall) {
scope.launch {
runCatching { readActivities(call) }
.onFailure { error -> call.reject(error.message ?: "Health Connect sync failed.") }
}
}
private suspend fun readActivities(call: PluginCall) {
val days = (call.getInt("days") ?: 14).coerceIn(1, 90).toLong()
val client = HealthConnectClient.getOrCreate(context)
val zone = ZoneId.systemDefault()
val today = LocalDate.now(zone)
val syncedAt = Instant.now().toString()
val activities = JSArray()
for (offset in 0 until days) {
val date = today.minusDays(offset)
val start = date.atStartOfDay(zone).toInstant()
val end = date.plusDays(1).atStartOfDay(zone).toInstant()
val aggregate = client.aggregate(
AggregateRequest(
metrics = setOf(StepsRecord.COUNT_TOTAL, ActiveCaloriesBurnedRecord.ACTIVE_CALORIES_TOTAL),
timeRangeFilter = TimeRangeFilter.between(start, end),
)
)
val steps = aggregate[StepsRecord.COUNT_TOTAL] ?: 0L
val calories = aggregate[ActiveCaloriesBurnedRecord.ACTIVE_CALORIES_TOTAL]?.inKilocalories ?: 0.0
if (steps > 0L || calories > 0.0) {
activities.put(
activityJson(
id = "health-connect-day-$date",
sourceName = "Health Connect",
type = "Daily activity",
title = "Daily activity",
startAt = start.toString(),
endAt = end.toString(),
calories = calories,
steps = steps,
syncedAt = syncedAt,
)
)
}
}
val rangeStart = today.minusDays(days - 1).atStartOfDay(zone).toInstant()
val rangeEnd = today.plusDays(1).atStartOfDay(zone).toInstant()
client.readRecords(
ReadRecordsRequest(
recordType = ExerciseSessionRecord::class,
timeRangeFilter = TimeRangeFilter.between(rangeStart, rangeEnd),
pageSize = 200,
)
).records.forEach { session ->
activities.put(
activityJson(
id = "health-connect-session-${session.metadata.id}",
sourceName = sourceName(session.metadata.dataOrigin),
type = "Exercise ${session.exerciseType}",
title = session.title ?: "Exercise session",
startAt = session.startTime.toString(),
endAt = session.endTime.toString(),
syncedAt = syncedAt,
)
)
}
call.resolve(JSObject().put("activities", activities))
}
private fun activityJson(
id: String,
sourceName: String,
type: String,
title: String,
startAt: String,
endAt: String,
calories: Double = 0.0,
steps: Long = 0L,
syncedAt: String,
): JSObject = JSObject()
.put("id", id)
.put("source", "health_connect")
.put("sourceName", sourceName)
.put("type", type)
.put("title", title)
.put("startAt", startAt)
.put("endAt", endAt)
.put("calories", calories)
.put("steps", steps)
.put("distanceMeters", 0)
.put("syncedAt", syncedAt)
private fun sourceName(origin: DataOrigin): String = origin.packageName.ifBlank { "Health Connect" }
override fun handleOnDestroy() {
super.handleOnDestroy()
scope.cancel()
}
}

View file

@ -1,5 +1,12 @@
package com.danvics.calorieai;
import android.os.Bundle;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {}
public class MainActivity extends BridgeActivity {
@Override
public void onCreate(Bundle savedInstanceState) {
registerPlugin(HealthConnectPlugin.class);
super.onCreate(savedInstanceState);
}
}

View file

@ -9,6 +9,7 @@ buildscript {
dependencies {
classpath 'com.android.tools.build:gradle:8.13.0'
classpath 'com.google.gms:google-services:4.4.4'
classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:2.0.21'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files

View file

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

View file

@ -28,16 +28,9 @@ services:
- /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_API_BASE: https://llm.danvics.com/v1
LITELLM_MODEL: openrouter-claude-haiku-4.5
ports:
- "127.0.0.1:8095:8080"
volumes:
- ./data:/app/data
networks:
- default
- litellm_default
networks:
litellm_default:
external: true

View file

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

View file

@ -1,48 +1,17 @@
<script>
import { onMount, tick } from 'svelte';
import Icon from './Icon.svelte';
import Field from './Field.svelte';
import Stat from './Stat.svelte';
import { onMount } from 'svelte';
import { registerPlugin } from '@capacitor/core';
import AppShell from './AppShell.svelte';
import DashboardPage from './DashboardPage.svelte';
import AnalyticsPage from './AnalyticsPage.svelte';
import MealForm from './MealForm.svelte';
import DiaryPage from './DiaryPage.svelte';
import SettingsPage from './SettingsPage.svelte';
import PlansPage from './PlansPage.svelte';
import { defaultSettings, fallbackModels, mealTypes, pages } from './appConfig.js';
import { calculateCaloriePlan, planSummary } from './calorie.js';
const pages = [
{ id: 'dashboard', label: 'Dashboard', icon: 'dashboard', group: 'Tracker' },
{ id: 'log', label: 'Log meal', icon: 'plus', group: 'Tracker' },
{ id: 'analytics', label: 'Analytics', icon: 'analytics', group: 'Tracker' },
{ id: 'diary', label: 'Diary', icon: 'diary', group: 'Tracker' },
{ id: 'trash', label: 'Trash', icon: 'trash', group: 'Tracker' },
{ id: 'plans', label: 'Plans', icon: 'plans', group: 'Tracker' },
{ id: 'settings', label: 'Settings', icon: 'settings', group: 'Admin' },
];
const mealTypes = ['Breakfast', 'Lunch', 'Dinner', 'Snack', 'Other'];
const fallbackModels = [
{ id: 'claude-haiku-4.5', name: 'Claude Haiku 4.5' },
{ id: 'claude-sonnet-4.6', name: 'Claude Sonnet 4.6' },
{ id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash' },
{ id: 'gpt-4o-mini', name: 'GPT-4o mini' },
];
const defaultSettings = {
visionModel: fallbackModels[0].id,
taskModel: fallbackModels[0].id,
sex: '',
age: '',
heightCm: '',
weightKg: '',
targetWeightKg: '',
goal: 'Lose weight',
calorieTarget: '',
activityLevel: 'Moderate',
pace: 'Steady',
motivation: '',
challenge: '',
trackingStyle: '',
calorieCountingExperience: '',
fastingInterest: '',
};
const HealthConnect = registerPlugin('HealthConnect');
let loginMode = location.pathname === '/login' || location.pathname === '/reset' || location.pathname === '/verify';
let username = 'admin';
@ -70,6 +39,7 @@
let modelStatus = '';
let modelError = false;
let modelSearching = false;
let modelActionId = '';
let settings = { ...defaultSettings };
let currentPassword = '';
let newPassword = '';
@ -78,6 +48,9 @@
let accountVerified = false;
let accountStatus = '';
let accountError = false;
let healthConnectStatus = 'Health Connect not synced.';
let healthConnectError = false;
let healthConnectSyncing = false;
let passwordStatus = '';
let passwordError = false;
let resetPasswordValue = '';
@ -107,11 +80,6 @@
let selectedEntryIds = [];
let trash = [];
let calorieCanvas;
let produceCanvas;
$: trackerPages = pages.filter(item => item.group === 'Tracker');
$: adminPages = pages.filter(item => item.group === 'Admin');
$: today = totalsFor(todayKey());
$: week = last7Days();
$: weekAverage = Math.round(week.reduce((sum, day) => sum + day.calories, 0) / week.length);
@ -138,22 +106,17 @@
$: diaryDays = Array.from(new Set(diaryRows.map(entry => entry.date))).sort((a, b) => b.localeCompare(a));
onMount(async () => {
await loadSession();
if (location.pathname === '/verify' && resetToken) await verifyAccountToken();
if (loginMode) return;
await loadSession();
page = localStorage.getItem('calorie_ai_last_tab') || 'dashboard';
setDefaultMealDateTime();
await Promise.all([loadSettingsFromServer(), loadEntriesFromServer(), loadActivitiesFromServer(), loadModels(), loadPlans()]);
await drawCharts();
await loadSettingsFromServer();
await loadModels();
await Promise.all([loadEntriesFromServer(), loadActivitiesFromServer(), loadPlans()]);
});
$: if (!loginMode) scheduleCharts(entries, page);
let chartTimer;
function scheduleCharts() {
clearTimeout(chartTimer);
chartTimer = setTimeout(drawCharts, 0);
}
function dateKey(date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
@ -282,10 +245,17 @@
async function loadSettingsFromServer() {
try {
const response = await fetch('/api/settings');
if (response.ok) settings = { ...defaultSettings, ...await response.json() };
if (response.ok) settings = normalizeSettings(await response.json());
} catch {}
}
function normalizeSettings(data = {}) {
const next = { ...defaultSettings, ...data };
if (!next.visionModel) next.visionModel = defaultSettings.visionModel;
if (!next.taskModel) next.taskModel = defaultSettings.taskModel;
return next;
}
async function saveSettings() {
try {
await fetch('/api/settings', {
@ -315,6 +285,30 @@
} catch {}
}
async function syncHealthConnect() {
healthConnectStatus = 'Syncing Health Connect...';
healthConnectError = false;
healthConnectSyncing = true;
try {
const nativeResult = await HealthConnect.sync({ days: 14 });
const synced = Array.isArray(nativeResult.activities) ? nativeResult.activities : [];
const response = await fetch('/api/activities/sync', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ source: 'health_connect', activities: synced }),
});
const data = await response.json().catch(() => ({ error: 'Health Connect server sync failed.' }));
if (!response.ok) throw new Error(data.error || 'Health Connect server sync failed.');
activities = data.activities || [];
healthConnectStatus = `Synced ${synced.length} Health Connect record${synced.length === 1 ? '' : 's'}.`;
} catch (error) {
healthConnectStatus = error?.message || 'Health Connect sync failed.';
healthConnectError = true;
} finally {
healthConnectSyncing = false;
}
}
function applyPlanConfig(config) {
plans = config.plans || [];
selectedPlanId = config.selectedPlanId || plans[0]?.id || '';
@ -362,6 +356,20 @@
return { mealName: '', calories: 0, proteinGrams: 0, carbsGrams: 0, fatGrams: 0, fruitServings: 0, vegetableServings: 0, foodGroups: '', notes: '' };
}
function normalizeEstimateDraft() {
return {
mealName: estimateDraft.mealName || description.trim() || 'Manual meal',
calories: Math.max(0, Math.round(Number(estimateDraft.calories) || 0)),
proteinGrams: Math.max(0, Math.round(Number(estimateDraft.proteinGrams) || 0)),
carbsGrams: Math.max(0, Math.round(Number(estimateDraft.carbsGrams) || 0)),
fatGrams: Math.max(0, Math.round(Number(estimateDraft.fatGrams) || 0)),
fruitServings: Math.max(0, Number(estimateDraft.fruitServings) || 0),
vegetableServings: Math.max(0, Number(estimateDraft.vegetableServings) || 0),
foodGroups: String(estimateDraft.foodGroups || ''),
notes: String(estimateDraft.notes || ''),
};
}
async function saveSelectedModels() {
await saveSettings();
modelStatus = 'Models saved.';
@ -398,43 +406,63 @@
}
async function addModel(model) {
const response = await fetch('/api/models/add', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(model),
});
if (!response.ok) {
modelStatus = (await response.json().catch(() => ({}))).error || 'Failed to add model.';
modelError = true;
return;
}
const data = await response.json();
models = data.models?.length ? data.models : models;
settings.visionModel = models.some(item => item.id === settings.visionModel) ? settings.visionModel : models[0].id;
settings.taskModel = model.id;
await saveSettings();
modelStatus = `Added ${model.name || model.id}.`;
modelActionId = model.id;
modelStatus = `Adding ${model.name || model.id}...`;
modelError = false;
try {
const response = await fetch('/api/models/add', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(model),
});
if (!response.ok) {
modelStatus = (await response.json().catch(() => ({}))).error || 'Failed to add model.';
modelError = true;
return;
}
const data = await response.json();
models = data.models?.length ? data.models : models;
settings.visionModel = models.some(item => item.id === settings.visionModel) ? settings.visionModel : models[0].id;
settings.taskModel = model.id;
await saveSettings();
modelStatus = `Added ${model.name || model.id}.`;
modelError = false;
} catch (error) {
modelStatus = error?.message || 'Failed to add model.';
modelError = true;
} finally {
modelActionId = '';
}
}
async function removeModel(modelId) {
const response = await fetch('/api/models/remove', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id: modelId }),
});
if (!response.ok) {
modelStatus = (await response.json().catch(() => ({}))).error || 'Failed to remove model.';
modelError = true;
return;
}
const data = await response.json();
models = data.models?.length ? data.models : fallbackModels;
if (!models.some(model => model.id === settings.visionModel)) settings.visionModel = data.defaultModel || models[0].id;
if (!models.some(model => model.id === settings.taskModel)) settings.taskModel = data.defaultModel || models[0].id;
await saveSettings();
modelStatus = 'Model removed.';
modelActionId = modelId;
modelStatus = 'Removing model...';
modelError = false;
try {
const response = await fetch('/api/models/remove', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id: modelId }),
});
if (!response.ok) {
modelStatus = (await response.json().catch(() => ({}))).error || 'Failed to remove model.';
modelError = true;
return;
}
const data = await response.json();
models = data.models?.length ? data.models : fallbackModels;
if (!models.some(model => model.id === settings.visionModel)) settings.visionModel = data.defaultModel || models[0].id;
if (!models.some(model => model.id === settings.taskModel)) settings.taskModel = data.defaultModel || models[0].id;
await saveSettings();
modelStatus = 'Model removed.';
modelError = false;
} catch (error) {
modelStatus = error?.message || 'Failed to remove model.';
modelError = true;
} finally {
modelActionId = '';
}
}
async function changePassword() {
@ -522,15 +550,32 @@
}
async function chatCompletion(body) {
const payload = { ...body, model: selectedModel(body.model) };
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ body }),
body: JSON.stringify({ body: payload }),
});
const text = await response.text();
if (!response.ok) throw new Error(text);
if (!response.ok) throw new Error(readApiError(text, 'AI request failed'));
const json = JSON.parse(text);
return json.choices[0].message.content.trim();
const content = json.choices?.[0]?.message?.content;
if (!content) throw new Error('AI returned an empty response.');
return content.trim();
}
function selectedModel(preferred) {
if (preferred && models.some(model => model.id === preferred)) return preferred;
return models[0]?.id || fallbackModels[0].id;
}
function readApiError(text, fallback) {
try {
const data = JSON.parse(text);
return data.error?.message || data.error || fallback;
} catch {
return text || fallback;
}
}
async function requestImageEstimate() {
@ -656,6 +701,7 @@
});
if (!response.ok) throw new Error((await response.json().catch(() => ({}))).error || 'Failed to save meal');
applyEntriesData(await response.json());
estimateDraft = emptyEstimate();
description = '';
measure = '';
selectedImageDataUrl = '';
@ -693,7 +739,7 @@
if (!existing) return;
const updated = {
...existing,
...estimateDraft,
...normalizeEstimateDraft(),
date: mealDate,
time: mealTime,
mealType,
@ -720,6 +766,47 @@
}
}
async function saveManualMeal() {
if (!mealDate || !mealTime || !mealType) return setMealStatus('Choose a date, time, and meal type.', true);
if (!description.trim() && !String(estimateDraft.mealName || '').trim()) return setMealStatus('Describe the meal or enter a meal name.', true);
const estimate = normalizeEstimateDraft();
if (!estimate.calories) return setMealStatus('Enter estimated calories before saving manually.', true);
savingMeal = true;
try {
const meal = {
...estimate,
id: editingEntryId || entryId(),
date: mealDate,
time: mealTime,
mealType,
description,
measure,
imageIncluded: Boolean(selectedImageDataUrl),
imageName: selectedImageName,
updatedAt: new Date().toISOString(),
};
const response = await fetch('/api/entries', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(meal),
});
if (!response.ok) throw new Error((await response.json().catch(() => ({}))).error || 'Failed to save meal');
applyEntriesData(await response.json());
estimateDraft = emptyEstimate();
description = '';
measure = '';
selectedImageDataUrl = '';
selectedImageName = '';
editingEntryId = '';
setDefaultMealDateTime();
setMealStatus(`Saved manually. ${estimate.mealName} · ${estimate.calories} kcal`);
} catch (error) {
setMealStatus(error.message, true);
} finally {
savingMeal = false;
}
}
function cancelEdit() {
editingEntryId = '';
estimateDraft = emptyEstimate();
@ -866,52 +953,6 @@ Include: daily calorie target, maintenance calories, protein target, meal loggin
if (response.ok) applyPlanConfig(await response.json());
}
async function drawCharts() {
await tick();
drawBars(calorieCanvas, week, 'calories');
drawBars(produceCanvas, week, 'produce');
}
function drawBars(canvas, days, mode) {
if (!canvas) return;
const dpr = window.devicePixelRatio || 1;
const cssW = canvas.offsetWidth || 400;
const cssH = canvas.offsetHeight || 200;
canvas.width = cssW * dpr;
canvas.height = cssH * dpr;
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
const values = days.map(day => mode === 'calories' ? day.calories : day.fruit + day.vegetables);
const max = Math.max(1, ...values);
const left = 4;
const bottom = cssH - 32;
const barHeight = cssH - 52;
const gap = 8;
const bar = (cssW - left * 2 - gap * 6) / 7;
ctx.textAlign = 'center';
days.forEach((day, index) => {
const x = left + index * (bar + gap);
const h = values[index] / max * barHeight;
ctx.fillStyle = '#e2e8f0';
roundRect(ctx, x, bottom - barHeight, bar, barHeight, 6);
ctx.fillStyle = mode === 'calories' ? '#2563eb' : '#f59e0b';
roundRect(ctx, x, bottom - h, bar, h, 6);
if (mode === 'produce' && day.vegetables > 0) {
const vegH = day.vegetables / max * barHeight;
ctx.fillStyle = '#10b981';
roundRect(ctx, x + bar * 0.52, bottom - vegH, bar * 0.48, vegH, 5);
}
ctx.fillStyle = '#64748b';
ctx.font = `${Math.max(10, Math.min(13, bar * 0.55))}px Inter, sans-serif`;
ctx.fillText(day.label, x + bar / 2, bottom + 20);
});
}
function roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.roundRect(x, y, w, Math.max(1, h), r);
ctx.fill();
}
</script>
{#if loginMode}
@ -968,137 +1009,20 @@ Include: daily calorie target, maintenance calories, protein target, meal loggin
</div>
</main>
{:else}
<div class="min-h-screen bg-slate-50 text-slate-900 lg:grid lg:grid-cols-[auto_1fr]">
<aside class:translate-x-0={menuOpen} class:collapsed-sidebar={sidebarCollapsed} class="sidebar-shell fixed inset-y-0 left-0 z-40 w-64 -translate-x-full overflow-hidden border-r border-slate-200 bg-white shadow-xl transition-all duration-200 lg:sticky lg:top-0 lg:h-screen lg:translate-x-0 lg:shadow-none lg:w-56">
<div class="sidebar-header flex h-16 items-center justify-between border-b border-slate-200 px-4">
<div class="flex min-w-0 items-center gap-3">
<div class="sidebar-logo grid h-9 w-9 flex-none place-items-center rounded-xl bg-blue-600 text-xs font-black text-white">CA</div>
<div class="sidebar-brand min-w-0">
<div class="truncate text-base font-black text-slate-950">Calorie AI</div>
<div class="text-xs text-slate-500">Food diary</div>
</div>
</div>
<button class="collapse-button hidden h-8 w-8 place-items-center rounded-lg text-slate-400 hover:bg-slate-100 hover:text-slate-700 lg:grid" type="button" on:click={() => sidebarCollapsed = !sidebarCollapsed} aria-label="Collapse menu">
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path fill-rule="evenodd" d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"/></svg>
</button>
<button class="rounded-md px-2.5 py-1 text-xs font-bold text-slate-600 hover:bg-slate-100 lg:hidden" type="button" on:click={() => menuOpen = false}>Close</button>
</div>
<nav class="p-2" aria-label="Main menu">
{#if trackerPages.length}<div class="sidebar-section-label px-3 py-2 text-[10px] font-bold uppercase tracking-widest text-slate-400">Tracker</div>{/if}
{#each trackerPages as item}
<button class:active-tab={page === item.id} class="tab-button" type="button" title={item.label} on:click={() => selectPage(item.id)}>
<span class="tab-icon"><Icon name={item.icon} /></span>
<span class="tab-label">{item.label}</span>
</button>
{/each}
{#if adminPages.length}<div class="sidebar-section-label px-3 py-2 text-[10px] font-bold uppercase tracking-widest text-slate-400">Admin</div>{/if}
{#each adminPages as item}
<button class:active-tab={page === item.id} class="tab-button" type="button" title={item.label} on:click={() => selectPage(item.id)}>
<span class="tab-icon"><Icon name={item.icon} /></span>
<span class="tab-label">{item.label}</span>
</button>
{/each}
</nav>
</aside>
{#if menuOpen}<button class="fixed inset-0 z-30 bg-slate-950/40 lg:hidden" type="button" aria-label="Close menu" on:click={() => menuOpen = false}></button>{/if}
<div class="min-w-0">
<header class="sticky top-0 z-20 border-b border-slate-200 bg-white/95 shadow-sm backdrop-blur">
<div class="flex h-16 items-center justify-between gap-3 px-4 lg:px-6">
<div class="flex min-w-0 items-center gap-3">
<button class="grid h-9 w-9 place-items-center rounded-xl bg-blue-600 text-white hover:bg-blue-700 lg:hidden" type="button" on:click={() => menuOpen = true} aria-label="Open menu">
<svg viewBox="0 0 20 20" fill="currentColor" width="18" height="18"><path fill-rule="evenodd" d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"/></svg>
</button>
<h1 class="truncate text-base font-bold text-slate-900">{pages.find(item => item.id === page)?.label || 'Dashboard'}</h1>
</div>
<button class="rounded-lg border border-slate-200 px-3 py-1.5 text-sm font-semibold text-slate-600 hover:border-slate-300 hover:text-slate-900" type="button" on:click={logout}>Sign out</button>
</div>
</header>
<main class="min-w-0 flex-1 p-4 lg:p-6">
<AppShell {pages} {page} bind:menuOpen bind:sidebarCollapsed {selectPage} {logout}>
{#if page === 'dashboard'}
<section class="space-y-4">
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
<article class="sm:col-span-2 rounded-2xl bg-gradient-to-br from-blue-600 to-indigo-700 p-5 text-white shadow-sm">
<div class="text-xs font-semibold uppercase tracking-widest text-blue-200">Today</div>
<div id="todayCalories" class="mt-1 text-4xl font-black tracking-tight">{today.calories} kcal</div>
{#if activeCalorieTarget}
<div class="mt-2 h-2 overflow-hidden rounded-full bg-white/20"><div class="h-full rounded-full bg-white" style="width:{targetPercent}%"></div></div>
<div class="mt-1 text-xs font-semibold text-blue-100">{Math.max(0, activeCalorieTarget - today.calories)} kcal left of {activeCalorieTarget}</div>
{/if}
<div id="todayMacroText" class="mt-2 text-sm font-medium text-blue-100">P {today.protein}g &nbsp;·&nbsp; C {today.carbs}g &nbsp;·&nbsp; F {today.fat}g</div>
<div id="todayProduceText" class="text-sm text-blue-200">Fruit {today.fruit.toFixed(1)} &nbsp;·&nbsp; Veg {today.vegetables.toFixed(1)}</div>
</article>
<Stat label="Meals today" value={today.meals} note={today.meals === 1 ? 'meal logged' : 'meals logged'} />
<Stat label="7-day average" value={`${weekAverage} kcal`} note="Calories per day" />
<Stat label="Activity today" value={`${Math.round(activityToday.calories)} kcal`} note={`${activityToday.steps} steps`} />
</div>
<div class="grid gap-4 lg:grid-cols-2">
<section class="card">
<h3 class="card-title">Macros today</h3>
<div class="space-y-3 p-4">
{#each [['Protein', today.protein, '#2563eb', 'bg-blue-500'], ['Carbs', today.carbs, '#f59e0b', 'bg-amber-400'], ['Fat', today.fat, '#7c3aed', 'bg-violet-500']] as [label, val, , cls]}
<div>
<div class="mb-1.5 flex justify-between text-sm">
<span class="font-semibold text-slate-700">{label}</span>
<span class="text-slate-500">{val}g &nbsp;·&nbsp; {Math.round(val / macroTotal * 100)}%</span>
</div>
<div class="h-2.5 overflow-hidden rounded-full bg-slate-100">
<div class="h-full rounded-full transition-all {cls}" style="width:{Math.round(val / macroTotal * 100)}%"></div>
</div>
</div>
{/each}
<div class="border-t border-slate-100 pt-2 text-xs text-slate-400">{today.calories} kcal total &nbsp;·&nbsp; produce {produceToday.toFixed(1)} / 5</div>
</div>
</section>
<section class="card">
<h3 class="card-title">Today's meals</h3>
<div class="divide-y divide-slate-100">
{#each todayEntries as entry}
<div class="flex items-center justify-between px-4 py-3">
<div class="min-w-0">
<div class="truncate text-sm font-semibold text-slate-800">{entry.mealName || entry.description}</div>
<div class="text-xs text-slate-400">{entry.mealType} · {entry.time}</div>
</div>
<div class="ml-3 shrink-0 text-sm font-bold text-slate-600">{entry.calories} kcal</div>
</div>
{:else}
<div class="px-4 py-8 text-center text-sm text-slate-400">No meals logged today</div>
{/each}
</div>
{#if todayEntries.length}
<div class="border-t border-slate-100 px-4 py-2">
<button class="text-xs font-semibold text-blue-600 hover:text-blue-700" type="button" on:click={() => selectPage('log')}>+ Log another meal</button>
</div>
{:else}
<div class="border-t border-slate-100 px-4 py-2">
<button class="text-xs font-semibold text-blue-600 hover:text-blue-700" type="button" on:click={() => selectPage('log')}>Log your first meal today</button>
</div>
{/if}
</section>
<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>
</section>
<DashboardPage
{today}
{activeCalorieTarget}
{targetPercent}
{macroTotal}
{produceToday}
{todayEntries}
{todayActivities}
{activityToday}
{weekAverage}
{selectPage}
/>
{:else if page === 'log'}
<MealForm
@ -1116,41 +1040,13 @@ Include: daily calorie target, maintenance calories, protein target, meal loggin
bind:estimateDraft
{chooseImage}
{saveMeal}
{saveManualMeal}
{saveMealEdits}
{cancelEdit}
/>
{:else if page === 'analytics'}
<section class="space-y-4">
<div>
<h2 class="text-xl font-bold">Analytics</h2>
<p class="text-sm text-slate-500">Seven-day calorie and produce trends.</p>
</div>
<div class="grid gap-4 xl:grid-cols-2">
<section class="card">
<h3 class="card-title">Calories — last 7 days</h3>
<div class="p-4" style="height:220px">
<canvas id="calorieChart" bind:this={calorieCanvas} class="w-full h-full"></canvas>
</div>
</section>
<section class="card">
<h3 class="card-title">Fruit and vegetables</h3>
<div class="p-4" style="height:220px">
<canvas id="produceChart" bind:this={produceCanvas} class="w-full h-full"></canvas>
</div>
</section>
</div>
<div class="grid gap-3 sm:grid-cols-3">
{#each week.slice().reverse().slice(0, 3) as day}
<article class="card p-4">
<div class="text-xs font-semibold text-slate-400">{day.date}</div>
<div class="mt-1 text-xl font-black text-slate-800">{day.calories} kcal</div>
<div class="text-xs text-slate-500">P {day.protein}g · C {day.carbs}g · F {day.fat}g</div>
<div class="text-xs text-slate-400">Fruit {day.fruit.toFixed(1)} · Veg {day.vegetables.toFixed(1)}</div>
</article>
{/each}
</div>
</section>
<AnalyticsPage {week} />
{:else if page === 'diary'}
<DiaryPage
@ -1209,10 +1105,14 @@ Include: daily calorie target, maintenance calories, protein target, meal loggin
{modelStatus}
{modelError}
{modelSearching}
{modelActionId}
bind:accountEmail
{accountVerified}
{accountStatus}
{accountError}
{healthConnectStatus}
{healthConnectError}
{healthConnectSyncing}
{passwordStatus}
{passwordError}
{resetPasswordValue}
@ -1226,23 +1126,10 @@ Include: daily calorie target, maintenance calories, protein target, meal loggin
{removeModel}
{saveAccountEmail}
{sendVerificationEmail}
{syncHealthConnect}
{changePassword}
{resetPassword}
/>
{/if}
</main>
</div>
</div>
</AppShell>
{/if}
<style>
.tab-button { display: flex; width: 100%; align-items: center; gap: 0.6rem; border-left: 3px solid transparent; border-radius: 0.5rem; padding: 0.55rem 0.85rem; text-align: left; font-size: 0.875rem; font-weight: 600; color: #64748b; transition: background 0.1s, color 0.1s; }
.tab-button:hover { background: #f8fafc; color: #0f172a; }
.active-tab { border-left-color: #2563eb; background: #eff6ff; color: #2563eb; }
.tab-icon { display: flex; width: 1.25rem; height: 1.25rem; flex: 0 0 1.25rem; align-items: center; justify-content: center; }
:global(.collapsed-sidebar) { width: 4.25rem !important; }
:global(.collapsed-sidebar .sidebar-header) { justify-content: center; padding-left: 0; padding-right: 0; }
:global(.collapsed-sidebar .tab-label), :global(.collapsed-sidebar .sidebar-section-label), :global(.collapsed-sidebar .sidebar-brand) { display: none; }
:global(.collapsed-sidebar .tab-button) { justify-content: center; padding-left: 0; padding-right: 0; }
:global(.collapsed-sidebar .collapse-button) { display: grid; }
</style>

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

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

View file

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

View file

@ -15,6 +15,7 @@
export let estimateDraft;
export let chooseImage;
export let saveMeal;
export let saveManualMeal;
export let saveMealEdits;
export let cancelEdit;
</script>
@ -43,8 +44,11 @@
</div>
</div>
{#if selectedImageDataUrl}<img id="preview" class="mx-4 mb-4 max-h-80 w-[calc(100%-2rem)] rounded-2xl object-cover" src={selectedImageDataUrl} alt="Selected meal preview">{/if}
{#if editingEntryId}
<div class="grid gap-3 border-t border-slate-200 p-4 md:grid-cols-4">
<div class="grid gap-3 border-t border-slate-200 p-4 md:grid-cols-4">
<div class="md:col-span-4">
<h3 class="text-sm font-black uppercase tracking-wide text-slate-500">Nutrition estimate</h3>
<p class="mt-1 text-sm text-slate-500">AI fills these automatically, or you can enter calories and save manually.</p>
</div>
<Field className="md:col-span-2" label="Meal name"><input class="input" bind:value={estimateDraft.mealName}></Field>
<Field label="Calories"><input class="input" type="number" min="0" bind:value={estimateDraft.calories}></Field>
<Field label="Protein (g)"><input class="input" type="number" min="0" bind:value={estimateDraft.proteinGrams}></Field>
@ -54,10 +58,10 @@
<Field label="Vegetable servings"><input class="input" type="number" min="0" step="0.1" bind:value={estimateDraft.vegetableServings}></Field>
<Field className="md:col-span-2" label="Food groups"><input class="input" bind:value={estimateDraft.foodGroups}></Field>
<Field className="md:col-span-2" label="Notes"><input class="input" bind:value={estimateDraft.notes}></Field>
</div>
{/if}
</div>
<div class="flex flex-col gap-3 border-t border-slate-200 p-4 md:flex-row md:items-center">
<button class="btn-primary" type="submit" disabled={savingMeal}>{savingMeal ? 'Saving...' : editingEntryId ? 'Analyze again' : 'Analyze and save'}</button>
<button class="btn-secondary" type="button" on:click={saveManualMeal} disabled={savingMeal}>Save manually</button>
{#if editingEntryId}<button class="btn-secondary" type="button" on:click={saveMealEdits}>Save edits</button><button class="btn-secondary" type="button" on:click={cancelEdit}>Cancel</button>{/if}
{#if status}<p id="status" class:text-red-600={statusError} class="text-sm font-semibold text-green-700">{status}</p>{/if}
</div>

View file

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

View file

@ -8,10 +8,14 @@
export let modelStatus;
export let modelError;
export let modelSearching;
export let modelActionId;
export let accountEmail;
export let accountVerified;
export let accountStatus;
export let accountError;
export let healthConnectStatus;
export let healthConnectError;
export let healthConnectSyncing;
export let passwordStatus;
export let passwordError;
export let resetPasswordValue;
@ -25,6 +29,7 @@
export let removeModel;
export let saveAccountEmail;
export let sendVerificationEmail;
export let syncHealthConnect;
export let changePassword;
export let resetPassword;
</script>
@ -65,7 +70,7 @@
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3">
<div class="mb-2 text-xs font-bold uppercase tracking-wide text-slate-500">Saved models</div>
<div class="max-h-40 overflow-y-auto pr-1">
{#each models as model}<div class="mb-2 flex items-center gap-2 rounded-lg bg-white px-3 py-2 text-sm shadow-sm"><span class="min-w-0 flex-1 truncate"><strong>{model.name}</strong> <span class="text-slate-400">{model.id}</span></span><button class="rounded-md bg-red-50 px-2 py-1 text-xs font-bold text-red-600 hover:bg-red-100" type="button" on:click={() => removeModel(model.id)}>Remove</button></div>{/each}
{#each models as model}<div class="mb-2 grid gap-2 rounded-lg bg-white px-3 py-2 text-sm shadow-sm sm:flex sm:items-center"><span class="min-w-0 flex-1 truncate"><strong>{model.name}</strong> <span class="text-slate-400">{model.id}</span></span><button class="rounded-lg bg-red-50 px-3 py-2 text-xs font-bold text-red-600 hover:bg-red-100 disabled:opacity-60" type="button" on:click={() => removeModel(model.id)} disabled={modelActionId === model.id}>{modelActionId === model.id ? 'Removing...' : 'Remove'}</button></div>{/each}
</div>
</div>
</div>
@ -73,19 +78,28 @@
<label class="mb-2 block text-sm font-bold text-slate-700" for="modelSearch">Search provider models</label>
<div class="flex flex-col gap-2 sm:flex-row"><input id="modelSearch" class="input" bind:value={modelSearch} placeholder="Search current models, e.g. claude, gemini, gpt" on:keydown={(event) => event.key === 'Enter' && searchModels()}><button class="btn-primary sm:w-36" type="button" on:click={searchModels} disabled={modelSearching}>{modelSearching ? 'Searching...' : 'Search'}</button></div>
<div class="mt-3 max-h-72 overflow-y-auto rounded-xl border border-slate-200 bg-white p-2">
{#each discoveredModels as model}<div class="mb-2 flex items-center gap-2 rounded-lg border border-slate-100 px-3 py-2 text-sm"><button class="rounded-md bg-blue-600 px-2.5 py-1 text-xs font-black text-white hover:bg-blue-700" type="button" on:click={() => addModel(model)}>+</button><span class="min-w-0 flex-1 truncate"><strong>{model.name}</strong> <span class="text-slate-400">{model.id}</span></span></div>{/each}
{#each discoveredModels as model}<div class="mb-2 grid gap-2 rounded-lg border border-slate-100 px-3 py-2 text-sm sm:flex sm:items-center"><span class="min-w-0 flex-1 truncate"><strong>{model.name}</strong> <span class="text-slate-400">{model.id}</span></span><button class="rounded-lg bg-blue-600 px-3 py-2 text-xs font-black text-white hover:bg-blue-700 disabled:opacity-60 sm:order-first" type="button" on:click={() => addModel(model)} disabled={modelActionId === model.id}>{modelActionId === model.id ? 'Adding...' : 'Add'}</button></div>{/each}
</div>
</div>
</div>
</section>
<section class="card xl:col-span-2">
<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 class="border-b border-slate-200 bg-gradient-to-r from-blue-50 to-slate-50 p-4">
<div class="text-xs font-black uppercase tracking-[0.24em] text-blue-700">Garmin and Health Connect</div>
<h3 class="mt-1 text-xl font-black text-slate-950">Connect Garmin from the Android app</h3>
<p class="mt-1 text-sm leading-6 text-slate-600">The web app cannot pair directly with a Garmin watch. Garmin writes to Android Health Connect, then Calorie AI imports that activity.</p>
</div>
<div class="grid gap-3 p-4 text-sm leading-6 text-slate-600 lg:grid-cols-3">
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm"><strong class="block text-slate-900">1. Garmin Connect</strong>Pair the watch and confirm it syncs in the Garmin Connect Android app.</div>
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm"><strong class="block text-slate-900">2. Health Connect</strong>In Garmin Connect, enable sharing to Android Health Connect. Garmin sends steps, calories, and workouts after each device sync.</div>
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm"><strong class="block text-slate-900">3. Calorie AI Android</strong>Open Calorie AI on Android, go to Settings, and tap Sync Health Connect. The web dashboard updates after the server receives the sync.</div>
</div>
<div class="flex flex-col gap-3 border-t border-slate-100 px-4 py-4 sm:flex-row sm:items-center">
<button class="btn-primary sm:w-52" type="button" on:click={syncHealthConnect} disabled={healthConnectSyncing}>{healthConnectSyncing ? 'Syncing...' : 'Sync Health Connect'}</button>
<p id="healthConnectStatus" class:text-red-600={healthConnectError} class="text-sm font-semibold text-green-700">{healthConnectStatus}</p>
</div>
<div class="border-t border-slate-100 bg-slate-50 px-4 py-3 text-xs leading-5 text-slate-500">Direct Garmin web login is not available because Garmin Health API requires partner approval and API credentials.</div>
</section>
</div>
</section>

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

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

View file

@ -6,15 +6,17 @@ const png = Buffer.from(
);
async function mockModels(page) {
let models = [
{ id: 'claude-haiku-4.5', name: 'Claude Haiku 4.5' },
];
await page.route('**/api/models', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
defaultModel: 'claude-haiku-4.5',
models: [
{ id: 'claude-haiku-4.5', name: 'Claude Haiku 4.5' },
],
models,
}),
});
});
@ -35,19 +37,28 @@ async function mockModels(page) {
await page.route('**/api/models/add', async (route) => {
const body = route.request().postDataJSON();
models = [...models.filter(model => model.id !== body.id), { id: body.id, name: body.name || body.id }];
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
ok: true,
defaultModel: 'claude-haiku-4.5',
models: [
{ id: 'claude-haiku-4.5', name: 'Claude Haiku 4.5' },
{ id: body.id, name: body.name || body.id },
],
models,
}),
});
});
await page.route('**/api/models/remove', async (route) => {
const body = route.request().postDataJSON();
models = models.filter(model => model.id !== body.id);
if (!models.length) models = [{ id: 'claude-haiku-4.5', name: 'Claude Haiku 4.5' }];
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ ok: true, defaultModel: models[0].id, models }),
});
});
}
async function mockPlans(page) {
@ -254,11 +265,14 @@ test.describe('authenticated app', () => {
await page.locator('#modelSearch').fill('gemini');
await page.getByRole('button', { name: 'Search' }).click();
await expect(page.getByText('Gemini 2.5 Flash')).toBeVisible();
await page.getByRole('button', { name: '+' }).last().click();
await page.getByRole('button', { name: 'Add' }).last().click();
await expect(page.locator('#modelStatus')).toContainText('Added Gemini 2.5 Flash.');
await page.locator('#visionModel').selectOption('gemini-2.5-flash');
await page.locator('#taskModel').selectOption('gemini-2.5-flash');
await page.getByRole('button', { name: 'Save selected models' }).click();
await expect(page.locator('#modelStatus')).toContainText('Models saved.');
await page.getByRole('button', { name: 'Remove' }).last().click();
await expect(page.locator('#modelStatus')).toContainText('Model removed.');
await page.getByRole('button', { name: 'Log meal' }).click();
await page.locator('#mealDate').fill('2026-05-18');