From 33d50258ce268c40c81779fa9f74e3395572b299 Mon Sep 17 00:00:00 2001 From: Daniel Date: Thu, 21 May 2026 03:40:47 +0200 Subject: [PATCH] Improve plan UX and split web UI components --- web/src/AnalyticsPage.svelte | 97 ++++++++++ web/src/App.svelte | 294 ++--------------------------- web/src/AppShell.svelte | 79 ++++++++ web/src/DashboardPage.svelte | 109 +++++++++++ web/src/PlansPage.svelte | 349 +++++++++++++++++++++++------------ web/src/SettingsPage.svelte | 15 +- web/src/appConfig.js | 37 ++++ 7 files changed, 580 insertions(+), 400 deletions(-) create mode 100644 web/src/AnalyticsPage.svelte create mode 100644 web/src/AppShell.svelte create mode 100644 web/src/DashboardPage.svelte create mode 100644 web/src/appConfig.js diff --git a/web/src/AnalyticsPage.svelte b/web/src/AnalyticsPage.svelte new file mode 100644 index 0000000..2e38e04 --- /dev/null +++ b/web/src/AnalyticsPage.svelte @@ -0,0 +1,97 @@ + + +
+
+

Analytics

+

Seven-day calorie and produce trends.

+
+
+
+

Calories - last 7 days

+
+ +
+
+
+

Fruit and vegetables

+
+ +
+
+
+
+ {#each week.slice().reverse().slice(0, 3) as day} +
+
{day.date}
+
{day.calories} kcal
+
P {day.protein}g · C {day.carbs}g · F {day.fat}g
+
Fruit {day.fruit.toFixed(1)} · Veg {day.vegetables.toFixed(1)}
+
+ {/each} +
+
diff --git a/web/src/App.svelte b/web/src/App.svelte index b4f0768..3a5ed4c 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -1,49 +1,15 @@ {#if loginMode} @@ -968,137 +874,20 @@ Include: daily calorie target, maintenance calories, protein target, meal loggin {:else} -
- - {#if menuOpen}{/if} - -
-
-
-
- -

{pages.find(item => item.id === page)?.label || 'Dashboard'}

-
- -
-
- -
+ {#if page === 'dashboard'} -
-
-
-
Today
-
{today.calories} kcal
- {#if activeCalorieTarget} -
-
{Math.max(0, activeCalorieTarget - today.calories)} kcal left of {activeCalorieTarget}
- {/if} -
P {today.protein}g  ·  C {today.carbs}g  ·  F {today.fat}g
-
Fruit {today.fruit.toFixed(1)}  ·  Veg {today.vegetables.toFixed(1)}
-
- - - -
- -
-
-

Macros today

-
- {#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]} -
-
- {label} - {val}g  ·  {Math.round(val / macroTotal * 100)}% -
-
-
-
-
- {/each} -
{today.calories} kcal total  ·  produce {produceToday.toFixed(1)} / 5
-
-
- -
-

Today's meals

-
- {#each todayEntries as entry} -
-
-
{entry.mealName || entry.description}
-
{entry.mealType} · {entry.time}
-
-
{entry.calories} kcal
-
- {:else} -
No meals logged today
- {/each} -
- {#if todayEntries.length} -
- -
- {:else} -
- -
- {/if} -
-
-

Synced activity

-
- {#each todayActivities.slice(0, 5) as activity} -
-
-
{activity.title || activity.type || 'Activity'}
-
{activity.sourceName || activity.source || 'External'} · {String(activity.startAt || '').slice(11, 16) || 'All day'}
-
-
- {Math.round(Number(activity.calories || 0))} kcal - {#if activity.steps}
{activity.steps} steps
{/if} -
-
- {:else} -
No activity synced yet. Connect Garmin or gym apps through Health Connect on Android.
- {/each} -
-
-
-
+ {:else if page === 'log'} {:else if page === 'analytics'} -
-
-

Analytics

-

Seven-day calorie and produce trends.

-
-
-
-

Calories — last 7 days

-
- -
-
-
-

Fruit and vegetables

-
- -
-
-
-
- {#each week.slice().reverse().slice(0, 3) as day} -
-
{day.date}
-
{day.calories} kcal
-
P {day.protein}g · C {day.carbs}g · F {day.fat}g
-
Fruit {day.fruit.toFixed(1)} · Veg {day.vegetables.toFixed(1)}
-
- {/each} -
-
+ {:else if page === 'diary'} {/if} -
-
-
+ {/if} - - diff --git a/web/src/AppShell.svelte b/web/src/AppShell.svelte new file mode 100644 index 0000000..828ee33 --- /dev/null +++ b/web/src/AppShell.svelte @@ -0,0 +1,79 @@ + + +
+ + {#if menuOpen}{/if} + +
+
+
+
+ +

{pageLabel}

+
+ +
+
+ +
+ +
+
+
+ + diff --git a/web/src/DashboardPage.svelte b/web/src/DashboardPage.svelte new file mode 100644 index 0000000..a82d405 --- /dev/null +++ b/web/src/DashboardPage.svelte @@ -0,0 +1,109 @@ + + +
+
+
+
Today
+
{today.calories} kcal
+ {#if activeCalorieTarget} +
+
{Math.max(0, activeCalorieTarget - today.calories)} kcal left of {activeCalorieTarget}
+ {/if} +
P {today.protein}g  ·  C {today.carbs}g  ·  F {today.fat}g
+
Fruit {today.fruit.toFixed(1)}  ·  Veg {today.vegetables.toFixed(1)}
+
+ + + +
+ +
+
+

Macros today

+
+ {#each [['Protein', today.protein, 'bg-blue-500'], ['Carbs', today.carbs, 'bg-amber-400'], ['Fat', today.fat, 'bg-violet-500']] as [label, val, cls]} +
+
+ {label} + {val}g  ·  {Math.round(val / macroTotal * 100)}% +
+
+
+
+
+ {/each} +
{today.calories} kcal total  ·  produce {produceToday.toFixed(1)} / 5
+
+
+ +
+

Today's meals

+
+ {#each todayEntries as entry} +
+
+
{entry.mealName || entry.description}
+
{entry.mealType} · {entry.time}
+
+
{entry.calories} kcal
+
+ {:else} +
No meals logged today
+ {/each} +
+ {#if todayEntries.length} +
+ +
+ {:else} +
+ +
+ {/if} +
+ +
+

Synced activity

+
+ {#each todayActivities.slice(0, 5) as activity} +
+
+
{activity.title || activity.type || 'Activity'}
+
{activity.sourceName || activity.source || 'External'} · {String(activity.startAt || '').slice(11, 16) || 'All day'}
+
+
+ {Math.round(Number(activity.calories || 0))} kcal + {#if activity.steps}
{activity.steps} steps
{/if} +
+
+ {:else} +
+
+
Connect Garmin
+

Use the Android app to sync your watch.

+

The web dashboard shows synced activity, but Garmin pairing runs through Garmin Connect and Android Health Connect.

+
+
    +
  1. 1. Sync your watch in Garmin Connect on Android.
  2. +
  3. 2. In Garmin Connect, enable sharing to Health Connect.
  4. +
  5. 3. Open Calorie AI on Android and tap Settings -> Sync Health Connect.
  6. +
+
+ {/each} +
+
+
+
diff --git a/web/src/PlansPage.svelte b/web/src/PlansPage.svelte index b8081bb..f06cab4 100644 --- a/web/src/PlansPage.svelte +++ b/web/src/PlansPage.svelte @@ -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' }); } -
-

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

- -
-
-

Profile

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

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

- {/if} - - {#if planStatus}

{planStatus}

{/if} +
+
+
+
+
Personal plan
+

Build a plan you can actually follow this week.

+

Set your basics, add the behavior details that usually make dieting hard, then keep each generated version as a clean document.

+
+ {plans.length || 0} saved version{plans.length === 1 ? '' : 's'} + {profileReady ? 'Ready to generate' : `Missing ${profileMissing.join(', ')}`} + {#if selectedPlan}Active v{selectedPlan.version || 1}{/if} +
-
-
-

Personalization

-
- - - - - +
+ {#each summaryCards as item} +
+
{item.label}
+
{item.value}
+
{item.note}
+
+ {/each}
-
- -
-

Current plan

-
- {#if selectedPlan} -
-
- {selectedPlan.title} -
Version {selectedPlan.version || 1} · {new Date(selectedPlan.createdAt).toLocaleString()}
-
- -
-
- {#each sections(selectedPlan.content) as section} -
-

{section.title}

-
    - {#each section.items as item}
  • {item}
  • {/each} -
-
- {/each} -
-
- - - -

These notes are sent to the AI to update your plan and are not stored afterwards.

- -
- {:else} -
No plans yet. Fill in your profile and click Generate.
- {/if} -
-
-
- - {#if plans.length > 1} -
-

Plan history

-
- {#each plans as plan} - - {/each}
- {/if} -
-

Frequently asked questions

-
- {#each faqs as faq, i} -
- - {#if faqOpen === i} -

{faq.a}

+
+ + +
+ {#if selectedPlan} +
+
+
+
+
Current plan
+

{selectedPlan.title}

+

Version {selectedPlan.version || 1} · saved {formatDate(selectedPlan.createdAt)}

+
+ +
+
+ {#each summaryCards as item} +
+
{item.label}
+
{item.value}
+
{item.note}
+
+ {/each} +
+
+ +
+ {#each planSections as section, index} +
+
+ {index + 1} +

{section.title}

+
+
    + {#each section.items as item} +
  • + + {item} +
  • + {/each} +
+
+ {:else} +
{selectedPlan.content}
+ {/each} +
+
+ +
+
+

Adjust this plan

+

Tell the AI what happened since the last version. The note is not saved, only the new plan is.

+
+
+ + + +
+ +

This keeps the current version in history and creates a new active version.

+
+
+
+ {:else} +
+
1
+

No plan yet

+

Fill in the body target and strategy sections, then generate your first saved plan.

+
+ {/if} + +
+

Plan questions

+
+ {#each faqs as faq, i} +
+ + {#if faqOpen === i} +

{faq.a}

+ {/if} +
+ {/each} +
+
-
+
diff --git a/web/src/SettingsPage.svelte b/web/src/SettingsPage.svelte index 810a2ac..f5ca523 100644 --- a/web/src/SettingsPage.svelte +++ b/web/src/SettingsPage.svelte @@ -80,12 +80,17 @@
-

Wearables and gym machines

-
-
GarminSync Garmin Connect to Android Health Connect, then tap Sync Health Connect in the Android app.
-
Gym machinesUse the machine or gym app to write workouts to Health Connect. Calorie AI imports the workout sessions and daily active calories.
-
PlanningSynced active calories and steps are saved on the server and included when generating or updating plans.
+
+
Garmin and Health Connect
+

Connect Garmin from the Android app

+

The web app cannot pair directly with a Garmin watch. Garmin writes to Android Health Connect, then Calorie AI imports that activity.

+
+
1. Garmin ConnectPair the watch and confirm it syncs in the Garmin Connect Android app.
+
2. Health ConnectIn Garmin Connect, enable sharing to Android Health Connect. Garmin sends steps, calories, and workouts after each device sync.
+
3. Calorie AI AndroidOpen Calorie AI on Android, go to Settings, and tap Sync Health Connect. The web dashboard updates after the server receives the sync.
+
+
Direct Garmin web login is not available because Garmin Health API requires partner approval and API credentials.
diff --git a/web/src/appConfig.js b/web/src/appConfig.js new file mode 100644 index 0000000..f85e745 --- /dev/null +++ b/web/src/appConfig.js @@ -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: '', +};