Compare commits
5 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd70da8c71 | ||
|
|
38d9f50c31 | ||
|
|
ef6b3a494c | ||
|
|
176a9fc3bb | ||
|
|
33d50258ce |
19 changed files with 1022 additions and 470 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ android {
|
|||
applicationId 'com.danvics.calorieai'
|
||||
minSdk 26
|
||||
targetSdk 35
|
||||
versionCode 4
|
||||
versionName '1.3'
|
||||
versionCode 11
|
||||
versionName '1.11'
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
97
web/src/AnalyticsPage.svelte
Normal file
97
web/src/AnalyticsPage.svelte
Normal 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>
|
||||
|
|
@ -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 · C {today.carbs}g · F {today.fat}g</div>
|
||||
<div id="todayProduceText" class="text-sm text-blue-200">Fruit {today.fruit.toFixed(1)} · Veg {today.vegetables.toFixed(1)}</div>
|
||||
</article>
|
||||
<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 · {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 · 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
79
web/src/AppShell.svelte
Normal 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>
|
||||
109
web/src/DashboardPage.svelte
Normal file
109
web/src/DashboardPage.svelte
Normal 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 · C {today.carbs}g · F {today.fat}g</div>
|
||||
<div id="todayProduceText" class="text-sm text-blue-200">Fruit {today.fruit.toFixed(1)} · Veg {today.vegetables.toFixed(1)}</div>
|
||||
</article>
|
||||
<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 · {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 · 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
37
web/src/appConfig.js
Normal 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: '',
|
||||
};
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in a new issue