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()}