mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-16 05:39:40 +02:00
feat(mana/web/body): full i18n + calorie × weight correlation chart
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) <noreply@anthropic.com>
This commit is contained in:
parent
bd231cd689
commit
967f938e84
9 changed files with 475 additions and 3 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<BodyExercise[]> = getContext('bodyExercises');
|
||||
const routines$: Observable<BodyRoutine[]> = getContext('bodyRoutines');
|
||||
|
|
@ -36,6 +38,7 @@
|
|||
const measurements$: Observable<BodyMeasurement[]> = getContext('bodyMeasurements');
|
||||
const checks$: Observable<BodyCheck[]> = getContext('bodyChecks');
|
||||
const phases$: Observable<BodyPhase[]> = getContext('bodyPhases');
|
||||
const meals$: Observable<MealWithNutrition[]> = getContext('bodyNutriphiMeals');
|
||||
|
||||
let exercises = $state<BodyExercise[]>([]);
|
||||
let routines = $state<BodyRoutine[]>([]);
|
||||
|
|
@ -44,6 +47,7 @@
|
|||
let measurements = $state<BodyMeasurement[]>([]);
|
||||
let checks = $state<BodyCheck[]>([]);
|
||||
let phases = $state<BodyPhase[]>([]);
|
||||
let meals = $state<MealWithNutrition[]>([]);
|
||||
|
||||
$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 @@
|
|||
<MeasurementForm />
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>{$_('body.calorieWeight', { default: 'Kalorien × Gewicht' })}</h2>
|
||||
<CalorieWeightChart {measurements} {meals} {activePhase} />
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>{$_('body.dailyCheck', { default: 'Heute' })}</h2>
|
||||
<DailyCheckCard {checks} />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,306 @@
|
|||
<!--
|
||||
CalorieWeightChart — Body × Nutriphi correlation view.
|
||||
|
||||
Overlays daily calorie intake (from nutriphi `meals`) against
|
||||
bodyweight (from `bodyMeasurements`) for the last N days. The
|
||||
whole point of having both modules in the same app is being
|
||||
able to ask "did the cut work?" without exporting CSVs.
|
||||
|
||||
Two y-axes (calories left, weight right), shared x. Pure SVG,
|
||||
same auto-scaling pattern as the other charts in this module —
|
||||
no chart-lib dependency.
|
||||
|
||||
When an active phase is supplied we draw a horizontal target-
|
||||
weight line so the user can see how far the run is from goal.
|
||||
|
||||
Data:
|
||||
- calorie buckets are summed per day across non-deleted meals
|
||||
with non-null nutrition.calories
|
||||
- weight series uses the latest weight reading per day (one
|
||||
person can step on the scale twice; we keep the last)
|
||||
- days with no data are simply omitted, the line interpolates
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { BodyMeasurement, BodyPhase } from '../types';
|
||||
import type { MealWithNutrition } from '$lib/modules/nutriphi/types';
|
||||
|
||||
interface Props {
|
||||
measurements: BodyMeasurement[];
|
||||
meals: MealWithNutrition[];
|
||||
activePhase?: BodyPhase | null;
|
||||
days?: number;
|
||||
height?: number;
|
||||
}
|
||||
const { measurements, meals, activePhase = null, days = 56, height = 160 }: Props = $props();
|
||||
|
||||
// ─ Date axis: last `days` days ending today ─
|
||||
let axis = $derived.by(() => {
|
||||
const out: string[] = [];
|
||||
const today = new Date();
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
const d = new Date(today);
|
||||
d.setDate(today.getDate() - i);
|
||||
out.push(d.toISOString().split('T')[0]);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
// ─ Calorie sum per day from meals ─
|
||||
let caloriesByDate = $derived.by(() => {
|
||||
const map = new Map<string, number>();
|
||||
for (const m of meals) {
|
||||
const cals = m.nutrition?.calories ?? 0;
|
||||
if (cals <= 0) continue;
|
||||
map.set(m.date, (map.get(m.date) ?? 0) + cals);
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
// ─ Latest weight per day ─
|
||||
let weightByDate = $derived.by(() => {
|
||||
const map = new Map<string, number>();
|
||||
for (const m of measurements) {
|
||||
if (m.type !== 'weight') continue;
|
||||
const existing = map.get(m.date);
|
||||
// Replace if this reading is created later than the kept one;
|
||||
// the BodyMeasurement type doesn't carry an in-day timestamp
|
||||
// so we just take the last one we encounter — order from
|
||||
// useAllBodyMeasurements is "by date desc" already, so we
|
||||
// only set if not already present.
|
||||
if (existing === undefined) {
|
||||
map.set(m.date, m.value);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
// ─ Restrict series to days that fall inside the axis window ─
|
||||
let calSeries = $derived(axis.map((d) => ({ date: d, value: caloriesByDate.get(d) ?? null })));
|
||||
let weightSeries = $derived(axis.map((d) => ({ date: d, value: weightByDate.get(d) ?? null })));
|
||||
|
||||
// ─ Auto-scaled extents (skip null buckets) ─
|
||||
function extent(values: (number | null)[]): { min: number; max: number } {
|
||||
const real = values.filter((v): v is number => v !== null);
|
||||
if (real.length === 0) return { min: 0, max: 1 };
|
||||
const min = Math.min(...real);
|
||||
const max = Math.max(...real);
|
||||
const pad = Math.max((max - min) * 0.15, 1);
|
||||
return { min: min - pad, max: max + pad };
|
||||
}
|
||||
let calExtent = $derived(extent(calSeries.map((p) => p.value)));
|
||||
let weightExtent = $derived(extent(weightSeries.map((p) => p.value)));
|
||||
|
||||
// ─ Counts so we can decide whether to render ─
|
||||
let calPoints = $derived(calSeries.filter((p) => p.value !== null).length);
|
||||
let weightPoints = $derived(weightSeries.filter((p) => p.value !== null).length);
|
||||
|
||||
// ─ Geometry ─
|
||||
const width = 480;
|
||||
const padX = 12;
|
||||
const padY = 14;
|
||||
|
||||
function plotPath(series: { value: number | null }[], ext: { min: number; max: number }): string {
|
||||
const range = ext.max - ext.min || 1;
|
||||
const stepX = (width - padX * 2) / Math.max(series.length - 1, 1);
|
||||
let d = '';
|
||||
let lastWasNull = true;
|
||||
series.forEach((p, i) => {
|
||||
if (p.value === null) {
|
||||
lastWasNull = true;
|
||||
return;
|
||||
}
|
||||
const x = padX + i * stepX;
|
||||
const y = padY + (height - padY * 2) * (1 - (p.value - ext.min) / range);
|
||||
d += `${lastWasNull ? 'M' : 'L'} ${x.toFixed(1)} ${y.toFixed(1)} `;
|
||||
lastWasNull = false;
|
||||
});
|
||||
return d.trim();
|
||||
}
|
||||
|
||||
let calPath = $derived(plotPath(calSeries, calExtent));
|
||||
let weightPath = $derived(plotPath(weightSeries, weightExtent));
|
||||
|
||||
// ─ Target weight overlay ─
|
||||
let targetY = $derived.by(() => {
|
||||
const target = activePhase?.targetWeight ?? null;
|
||||
if (target === null) return null;
|
||||
const range = weightExtent.max - weightExtent.min || 1;
|
||||
// Only render the target line if it falls within the visible range,
|
||||
// otherwise it gets clamped to the edge and looks like a meaningless
|
||||
// boundary stripe. The user can read the explicit value from the legend.
|
||||
if (target < weightExtent.min || target > weightExtent.max) return null;
|
||||
return padY + (height - padY * 2) * (1 - (target - weightExtent.min) / range);
|
||||
});
|
||||
|
||||
// ─ Trend deltas (first → last non-null point of each series) ─
|
||||
function trendDelta(series: { value: number | null }[]): number | null {
|
||||
const real = series.filter((p): p is { value: number } => p.value !== null);
|
||||
if (real.length < 2) return null;
|
||||
return real[real.length - 1].value - real[0].value;
|
||||
}
|
||||
let calDelta = $derived(trendDelta(calSeries));
|
||||
let weightDelta = $derived(trendDelta(weightSeries));
|
||||
|
||||
function avgCalories(): number | null {
|
||||
const real = calSeries.map((p) => p.value).filter((v): v is number => v !== null);
|
||||
if (real.length === 0) return null;
|
||||
return Math.round(real.reduce((s, v) => s + v, 0) / real.length);
|
||||
}
|
||||
let avg = $derived(avgCalories());
|
||||
</script>
|
||||
|
||||
<div class="chart">
|
||||
{#if calPoints === 0 && weightPoints === 0}
|
||||
<p class="empty">Noch keine überlappenden Daten — logge Mahlzeiten und Gewicht parallel.</p>
|
||||
{:else}
|
||||
<div class="legend">
|
||||
<div class="series cal">
|
||||
<span class="dot"></span>
|
||||
<div>
|
||||
<div class="series-label">Ø Kalorien / Tag</div>
|
||||
<div class="series-value">
|
||||
{avg ?? '—'}
|
||||
{#if calDelta !== null}
|
||||
<span class="delta" class:up={calDelta > 0} class:down={calDelta < 0}>
|
||||
{calDelta > 0 ? '+' : ''}{Math.round(calDelta)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="series weight">
|
||||
<span class="dot"></span>
|
||||
<div>
|
||||
<div class="series-label">Gewicht</div>
|
||||
<div class="series-value">
|
||||
{weightSeries.filter((p) => p.value !== null).slice(-1)[0]?.value ?? '—'}
|
||||
<span class="unit">kg</span>
|
||||
{#if weightDelta !== null}
|
||||
<span class="delta" class:up={weightDelta > 0} class:down={weightDelta < 0}>
|
||||
{weightDelta > 0 ? '+' : ''}{weightDelta.toFixed(1)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<svg viewBox="0 0 {width} {height}" preserveAspectRatio="none">
|
||||
{#if targetY !== null}
|
||||
<line
|
||||
x1={padX}
|
||||
x2={width - padX}
|
||||
y1={targetY}
|
||||
y2={targetY}
|
||||
stroke="hsl(var(--color-muted-foreground))"
|
||||
stroke-width="1"
|
||||
stroke-dasharray="3 3"
|
||||
opacity="0.6"
|
||||
/>
|
||||
{/if}
|
||||
<path
|
||||
d={calPath}
|
||||
fill="none"
|
||||
stroke="hsl(35 90% 55%)"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d={weightPath}
|
||||
fill="none"
|
||||
stroke="hsl(217 91% 60%)"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<div class="footer">
|
||||
Letzte {days} Tage
|
||||
{#if activePhase}
|
||||
· Phase: <span class="phase-name">{activePhase.kind}</span>
|
||||
{#if activePhase.targetWeight}
|
||||
· Ziel: {activePhase.targetWeight}kg
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.chart {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
.legend {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.series {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.dot {
|
||||
width: 0.625rem;
|
||||
height: 0.625rem;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.cal .dot {
|
||||
background: hsl(35 90% 55%);
|
||||
}
|
||||
.weight .dot {
|
||||
background: hsl(217 91% 60%);
|
||||
}
|
||||
.series-label {
|
||||
font-size: 0.625rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.series-value {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-foreground));
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.unit {
|
||||
font-size: 0.6875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-weight: 400;
|
||||
}
|
||||
.delta {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
.delta.up {
|
||||
color: hsl(0 84% 60%);
|
||||
}
|
||||
.delta.down {
|
||||
color: hsl(142 71% 45%);
|
||||
}
|
||||
svg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
.footer {
|
||||
font-size: 0.6875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.phase-name {
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.empty {
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
text-align: center;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -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<LocalMeal>('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. */
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue