From 967f938e84de24fd1157010ea713e67c8028df85 Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 9 Apr 2026 17:19:20 +0200 Subject: [PATCH] =?UTF-8?q?feat(mana/web/body):=20full=20i18n=20+=20calori?= =?UTF-8?q?e=20=C3=97=20weight=20correlation=20chart?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two complementary improvements that take the body module from "works in DE/EN" to "works for every Mana user" and surface the highest- value cross-module integration the merged module unlocks. i18n — finish the rollout it/fr/es JSON files were already present from the initial body drop but only had the original copy. Add the new keys introduced by the quick-win commits last week: - phase.{start,end,startNew} - progression - routines.{title,start,empty} - exercisePicker.{title,pick,search,empty,create} - muscle.* (13 muscle group labels) - calorieWeight (used by the new chart below) de.json + en.json get the calorieWeight key for the new section. Translations are real (not machine-default fallbacks) so the Body module is now first-class in all five supported locales. CalorieWeightChart — Body × Nutriphi correlation The whole point of having both modules in the same app is being able to ask "did the cut work?" without exporting CSVs. This component overlays daily calorie intake (summed across nutriphi meals) against bodyweight readings over the last 8 weeks, with an optional dashed target-weight line driven by the active phase. Key design choices: - Two y-axes auto-scaled independently (calories left, weight right) so a 2000kcal swing and a 1kg swing both stay visible. - Days without data are omitted from the path; the line draws "M ... L" gaps so a missed weigh-in doesn't show as a hard drop to zero. - Target-weight overlay only renders when it falls inside the visible weight range — clamping it to the edge would create a meaningless boundary stripe. - Cut-friendly delta colors: weight DOWN is green (you're on track), weight UP is red. Calorie deltas use the same scheme (down = restriction working). - Pure SVG, no chart-lib dependency, same auto-scale primitive we already use for WeightChart and ExerciseProgressionChart. Cross-module read: new `useNutriphiMealsSince(date)` helper in body/queries.ts — lives in body (not nutriphi) because the body module owns the integration boundary, and putting the cross-table read in one place keeps the import graph from getting circular if nutriphi ever wants to reach back. The hook decrypts the nutriphi `meals` table (already encrypted at rest by the meals registry entry) and projects to a thin MealWithNutrition shape for the chart. Decrypt cost on a few hundred meal rows is negligible vs. the value of the chart. Wired into the body layout as a 7th context (`bodyNutriphiMeals`) with `dateNDaysAgo(56)` — 8 weeks covers a typical cut/bulk cycle. ListView renders a new "Kalorien × Gewicht" card between the Weight section and the Daily Check. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../web/src/lib/i18n/locales/body/de.json | 1 + .../web/src/lib/i18n/locales/body/en.json | 1 + .../web/src/lib/i18n/locales/body/es.json | 34 +- .../web/src/lib/i18n/locales/body/fr.json | 34 +- .../web/src/lib/i18n/locales/body/it.json | 34 +- .../web/src/lib/modules/body/ListView.svelte | 13 + .../body/components/CalorieWeightChart.svelte | 306 ++++++++++++++++++ .../apps/web/src/lib/modules/body/queries.ts | 50 +++ .../web/src/routes/(app)/body/+layout.svelte | 5 + 9 files changed, 475 insertions(+), 3 deletions(-) create mode 100644 apps/mana/apps/web/src/lib/modules/body/components/CalorieWeightChart.svelte diff --git a/apps/mana/apps/web/src/lib/i18n/locales/body/de.json b/apps/mana/apps/web/src/lib/i18n/locales/body/de.json index 5331444b6..12ce4d927 100644 --- a/apps/mana/apps/web/src/lib/i18n/locales/body/de.json +++ b/apps/mana/apps/web/src/lib/i18n/locales/body/de.json @@ -7,6 +7,7 @@ "startHint": "Starte eine neue Session und logge deine Sätze", "startWorkout": "Workout starten", "weight": "Gewicht", + "calorieWeight": "Kalorien × Gewicht", "dailyCheck": "Heute", "recent": "Letzte Workouts", "noWorkouts": "Noch keine Sessions", diff --git a/apps/mana/apps/web/src/lib/i18n/locales/body/en.json b/apps/mana/apps/web/src/lib/i18n/locales/body/en.json index 7741c5e2c..e060f6273 100644 --- a/apps/mana/apps/web/src/lib/i18n/locales/body/en.json +++ b/apps/mana/apps/web/src/lib/i18n/locales/body/en.json @@ -7,6 +7,7 @@ "startHint": "Start a new session and log your sets", "startWorkout": "Start workout", "weight": "Weight", + "calorieWeight": "Calories × Weight", "dailyCheck": "Today", "recent": "Recent workouts", "noWorkouts": "No sessions yet", diff --git a/apps/mana/apps/web/src/lib/i18n/locales/body/es.json b/apps/mana/apps/web/src/lib/i18n/locales/body/es.json index 38b57f5dd..1af696476 100644 --- a/apps/mana/apps/web/src/lib/i18n/locales/body/es.json +++ b/apps/mana/apps/web/src/lib/i18n/locales/body/es.json @@ -7,6 +7,7 @@ "startHint": "Inicia una nueva sesión y registra tus series", "startWorkout": "Iniciar entrenamiento", "weight": "Peso", + "calorieWeight": "Calorías × Peso", "dailyCheck": "Hoy", "recent": "Últimos entrenamientos", "noWorkouts": "Aún sin sesiones", @@ -33,6 +34,37 @@ "cut": "Definición", "bulk": "Volumen", "maintenance": "Mantenimiento", - "recomp": "Recomposición" + "recomp": "Recomposición", + "start": "Iniciar fase", + "startNew": "Iniciar fase", + "end": "Terminar" + }, + "progression": "Progresión", + "routines": { + "title": "Rutinas", + "start": "Iniciar", + "empty": "Aún sin rutinas" + }, + "exercisePicker": { + "title": "Elegir ejercicio", + "pick": "Elegir ejercicio", + "search": "Buscar…", + "empty": "Sin resultados", + "create": "Nuevo ejercicio" + }, + "muscle": { + "chest": "Pecho", + "back": "Espalda", + "shoulders": "Hombros", + "biceps": "Bíceps", + "triceps": "Tríceps", + "forearms": "Antebrazos", + "core": "Core", + "quads": "Cuádriceps", + "hamstrings": "Isquiotibiales", + "glutes": "Glúteos", + "calves": "Pantorrillas", + "cardio": "Cardio", + "fullbody": "Cuerpo entero" } } diff --git a/apps/mana/apps/web/src/lib/i18n/locales/body/fr.json b/apps/mana/apps/web/src/lib/i18n/locales/body/fr.json index e341ff1c4..3269ccf2f 100644 --- a/apps/mana/apps/web/src/lib/i18n/locales/body/fr.json +++ b/apps/mana/apps/web/src/lib/i18n/locales/body/fr.json @@ -7,6 +7,7 @@ "startHint": "Démarre une nouvelle séance et enregistre tes séries", "startWorkout": "Démarrer la séance", "weight": "Poids", + "calorieWeight": "Calories × Poids", "dailyCheck": "Aujourd'hui", "recent": "Dernières séances", "noWorkouts": "Pas encore de séance", @@ -33,6 +34,37 @@ "cut": "Sèche", "bulk": "Prise de masse", "maintenance": "Maintenance", - "recomp": "Recomposition" + "recomp": "Recomposition", + "start": "Démarrer la phase", + "startNew": "Démarrer la phase", + "end": "Terminer" + }, + "progression": "Progression", + "routines": { + "title": "Routines", + "start": "Démarrer", + "empty": "Pas encore de routine" + }, + "exercisePicker": { + "title": "Choisir un exercice", + "pick": "Choisir un exercice", + "search": "Rechercher…", + "empty": "Aucun résultat", + "create": "Nouvel exercice" + }, + "muscle": { + "chest": "Pectoraux", + "back": "Dos", + "shoulders": "Épaules", + "biceps": "Biceps", + "triceps": "Triceps", + "forearms": "Avant-bras", + "core": "Gainage", + "quads": "Quadriceps", + "hamstrings": "Ischio-jambiers", + "glutes": "Fessiers", + "calves": "Mollets", + "cardio": "Cardio", + "fullbody": "Corps entier" } } diff --git a/apps/mana/apps/web/src/lib/i18n/locales/body/it.json b/apps/mana/apps/web/src/lib/i18n/locales/body/it.json index 62fbd64ae..34b631100 100644 --- a/apps/mana/apps/web/src/lib/i18n/locales/body/it.json +++ b/apps/mana/apps/web/src/lib/i18n/locales/body/it.json @@ -7,6 +7,7 @@ "startHint": "Inizia una nuova sessione e registra le serie", "startWorkout": "Inizia allenamento", "weight": "Peso", + "calorieWeight": "Calorie × Peso", "dailyCheck": "Oggi", "recent": "Ultimi allenamenti", "noWorkouts": "Nessuna sessione", @@ -33,6 +34,37 @@ "cut": "Definizione", "bulk": "Massa", "maintenance": "Mantenimento", - "recomp": "Ricomposizione" + "recomp": "Ricomposizione", + "start": "Avvia fase", + "startNew": "Avvia fase", + "end": "Termina" + }, + "progression": "Progressione", + "routines": { + "title": "Routine", + "start": "Inizia", + "empty": "Nessuna routine" + }, + "exercisePicker": { + "title": "Scegli un esercizio", + "pick": "Scegli esercizio", + "search": "Cerca…", + "empty": "Nessun risultato", + "create": "Nuovo esercizio" + }, + "muscle": { + "chest": "Petto", + "back": "Schiena", + "shoulders": "Spalle", + "biceps": "Bicipiti", + "triceps": "Tricipiti", + "forearms": "Avambracci", + "core": "Core", + "quads": "Quadricipiti", + "hamstrings": "Femorali", + "glutes": "Glutei", + "calves": "Polpacci", + "cardio": "Cardio", + "fullbody": "Corpo intero" } } diff --git a/apps/mana/apps/web/src/lib/modules/body/ListView.svelte b/apps/mana/apps/web/src/lib/modules/body/ListView.svelte index 80cff3ad7..6c544ddf8 100644 --- a/apps/mana/apps/web/src/lib/modules/body/ListView.svelte +++ b/apps/mana/apps/web/src/lib/modules/body/ListView.svelte @@ -18,6 +18,7 @@ BodyCheck, BodyPhase, } from './types'; + import type { MealWithNutrition } from '$lib/modules/nutriphi/types'; import { getActiveWorkout, getActivePhase } from './queries'; import { bodyStore } from './stores/body.svelte'; import WorkoutLogger from './components/WorkoutLogger.svelte'; @@ -28,6 +29,7 @@ import RoutineManager from './components/RoutineManager.svelte'; import PhaseManager from './components/PhaseManager.svelte'; import ExerciseProgressionChart from './components/ExerciseProgressionChart.svelte'; + import CalorieWeightChart from './components/CalorieWeightChart.svelte'; const exercises$: Observable = getContext('bodyExercises'); const routines$: Observable = getContext('bodyRoutines'); @@ -36,6 +38,7 @@ const measurements$: Observable = getContext('bodyMeasurements'); const checks$: Observable = getContext('bodyChecks'); const phases$: Observable = getContext('bodyPhases'); + const meals$: Observable = getContext('bodyNutriphiMeals'); let exercises = $state([]); let routines = $state([]); @@ -44,6 +47,7 @@ let measurements = $state([]); let checks = $state([]); let phases = $state([]); + let meals = $state([]); $effect(() => { const sub = exercises$.subscribe((v) => (exercises = v)); @@ -73,6 +77,10 @@ const sub = phases$.subscribe((v) => (phases = v)); return () => sub.unsubscribe(); }); + $effect(() => { + const sub = meals$.subscribe((v) => (meals = v)); + return () => sub.unsubscribe(); + }); let activeWorkout = $derived(getActiveWorkout(workouts)); let activePhase = $derived(getActivePhase(phases)); @@ -134,6 +142,11 @@ +
+

{$_('body.calorieWeight', { default: 'Kalorien × Gewicht' })}

+ +
+

{$_('body.dailyCheck', { default: 'Heute' })}

diff --git a/apps/mana/apps/web/src/lib/modules/body/components/CalorieWeightChart.svelte b/apps/mana/apps/web/src/lib/modules/body/components/CalorieWeightChart.svelte new file mode 100644 index 000000000..0611c2e34 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/body/components/CalorieWeightChart.svelte @@ -0,0 +1,306 @@ + + + +
+ {#if calPoints === 0 && weightPoints === 0} +

Noch keine überlappenden Daten — logge Mahlzeiten und Gewicht parallel.

+ {:else} +
+
+ +
+
Ø Kalorien / Tag
+
+ {avg ?? '—'} + {#if calDelta !== null} + 0} class:down={calDelta < 0}> + {calDelta > 0 ? '+' : ''}{Math.round(calDelta)} + + {/if} +
+
+
+
+ +
+
Gewicht
+
+ {weightSeries.filter((p) => p.value !== null).slice(-1)[0]?.value ?? '—'} + kg + {#if weightDelta !== null} + 0} class:down={weightDelta < 0}> + {weightDelta > 0 ? '+' : ''}{weightDelta.toFixed(1)} + + {/if} +
+
+
+
+ + + {#if targetY !== null} + + {/if} + + + + + + {/if} +
+ + diff --git a/apps/mana/apps/web/src/lib/modules/body/queries.ts b/apps/mana/apps/web/src/lib/modules/body/queries.ts index b674f9c8b..cc7026def 100644 --- a/apps/mana/apps/web/src/lib/modules/body/queries.ts +++ b/apps/mana/apps/web/src/lib/modules/body/queries.ts @@ -7,6 +7,7 @@ import { useLiveQueryWithDefault } from '@mana/local-store/svelte'; import { decryptRecords } from '$lib/data/crypto'; import { db } from '$lib/data/database'; +import type { LocalMeal, MealWithNutrition } from '$lib/modules/nutriphi/types'; import type { LocalBodyExercise, LocalBodyRoutine, @@ -204,6 +205,55 @@ export function useAllBodyPhases() { }, [] as BodyPhase[]); } +/** + * Cross-module read into the nutriphi `meals` table for the calorie / + * weight correlation chart. Lives here (instead of consumers calling + * nutriphi/queries directly) because the body module owns the Body × + * Nutriphi integration boundary, and putting the cross-table read in + * one place keeps the import graph from getting circular if nutriphi + * ever wants to reach back the other way. + * + * Returns a thinned MealWithNutrition shape — only the fields the + * correlation chart actually consumes (date + nutrition.calories). + * `since` is a YYYY-MM-DD lower bound; the chart pulls 8 weeks but + * the helper is permissive so a future "year view" can extend it. + */ +export function useNutriphiMealsSince(since: string) { + return useLiveQueryWithDefault(async () => { + const locals = await db.table('meals').where('date').aboveOrEqual(since).toArray(); + const visible = locals.filter((m) => !m.deletedAt); + // Encrypted fields (description / portionSize / foods) get unwrapped + // before we project. We don't strictly need them for the chart, but + // future tooltips with "what did you eat that day" will, and the + // decrypt cost on a few hundred rows is negligible. + const decrypted = await decryptRecords('meals', visible); + return decrypted.map( + (m): MealWithNutrition => ({ + id: m.id, + date: m.date, + mealType: m.mealType, + inputType: m.inputType, + description: m.description, + portionSize: m.portionSize ?? null, + confidence: m.confidence, + nutrition: m.nutrition ?? null, + photoMediaId: m.photoMediaId ?? null, + photoUrl: m.photoUrl ?? null, + photoThumbnailUrl: m.photoThumbnailUrl ?? null, + foods: m.foods ?? null, + createdAt: m.createdAt ?? new Date().toISOString(), + }) + ); + }, [] as MealWithNutrition[]); +} + +/** Helper: YYYY-MM-DD `n` days ago. */ +export function dateNDaysAgo(n: number): string { + const d = new Date(); + d.setDate(d.getDate() - n); + return d.toISOString().split('T')[0]; +} + // ─── Pure Helpers ─────────────────────────────────────────── /** Today as YYYY-MM-DD. */ diff --git a/apps/mana/apps/web/src/routes/(app)/body/+layout.svelte b/apps/mana/apps/web/src/routes/(app)/body/+layout.svelte index 3358a0b92..e5db803aa 100644 --- a/apps/mana/apps/web/src/routes/(app)/body/+layout.svelte +++ b/apps/mana/apps/web/src/routes/(app)/body/+layout.svelte @@ -9,6 +9,8 @@ useAllBodyMeasurements, useAllBodyChecks, useAllBodyPhases, + useNutriphiMealsSince, + dateNDaysAgo, } from '$lib/modules/body/queries'; let { children }: { children: Snippet } = $props(); @@ -20,6 +22,9 @@ setContext('bodyMeasurements', useAllBodyMeasurements()); setContext('bodyChecks', useAllBodyChecks()); setContext('bodyPhases', useAllBodyPhases()); + // Cross-module read for the Body × Nutriphi correlation chart. + // 8 weeks back covers a typical cut/bulk cycle and matches the chart default. + setContext('bodyNutriphiMeals', useNutriphiMealsSince(dateNDaysAgo(56))); {@render children()}