feat(mana/web/body): UI components, route, i18n, dashboard widget

Builds the user-facing surface on top of the data layer landed in the
previous commit. After this commit the Body module is reachable at
/body and surfaces an at-a-glance tile on the customizable dashboard.

Components (lib/modules/body/components/):
  - SetRow — inline editable set with weight/reps/RPE/warmup/delete.
    Local $state mirrors the prop and re-syncs via $effect when the
    parent re-emits the row through liveQuery.
  - WorkoutLogger — active-session console. Groups sets by exercise,
    pre-fills the next-set form from the most recent working set on
    the same exercise so progressive overload is one tap.
  - MeasurementForm — quick-log with type picker; unit auto-follows
    (kg for weight/muscle, % for body fat, cm for circumferences).
  - WeightChart — pure SVG line chart, no chart-lib dependency.
    Auto-scales the y-axis with padding so flat-line periods don't
    collapse to a single horizontal line.
  - DailyCheckCard — 1-5 dot buttons for energy/sleep/soreness/mood,
    upserts to bodyChecks per day so re-tapping overwrites today.
  - RecentWorkouts — finished sessions with set count, total volume,
    duration.

ListView.svelte composes everything into the main view: active
workout console when running (otherwise a "start" CTA), weight
chart + measurement form, today's daily check card, recent
workouts. Phase pill in the header (Cut/Bulk/Maintenance) with
color-coded background.

Route (routes/(app)/body/):
  - +layout.svelte sets seven contexts via the useAllBody*() hooks
    so child pages get observable streams without prop drilling.
  - +page.svelte renders ListView.

i18n (lib/i18n/locales/body/):
  - de/en/it/fr/es JSON files with title, subtitle, workout state,
    measurement.* (10 types), check.* (4 fields), phase.* (4 kinds),
    log/finish/start strings.
  - Registered in lib/i18n/index.ts alongside the other module dicts.

Dashboard widget (lib/modules/body/widgets/BodyStatsWidget.svelte):
  - Surfaces latest weight + delta vs the previous reading, plus
    either the active workout (with today's set count + volume) or
    a "start workout" CTA when idle.
  - Reads bodyMeasurements / bodyWorkouts / bodySets directly via
    liveQuery + decryptRecords (same pattern as NewsUnreadWidget).
  - Wired into widget-registry.ts as 'body-stats', registered in
    types/dashboard.ts WIDGET_REGISTRY with 💪 icon and the new
    'body' requiredBackend tier.
  - Strings added under dashboard.widgets.body_stats.* in all five
    locales.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-09 16:28:55 +02:00
parent a412ccc6fb
commit b2db42bb26
23 changed files with 1439 additions and 1 deletions

View file

@ -32,6 +32,7 @@ import NutritionProgressWidget from '$lib/modules/core/widgets/NutritionProgress
import PlantWateringWidget from '$lib/modules/core/widgets/PlantWateringWidget.svelte';
import CyclesWidget from '$lib/modules/core/widgets/CyclesWidget.svelte';
import NewsUnreadWidget from '$lib/modules/news/widgets/NewsUnreadWidget.svelte';
import BodyStatsWidget from '$lib/modules/body/widgets/BodyStatsWidget.svelte';
import DayTimelineWidget from './widgets/DayTimelineWidget.svelte';
import ActivityFeedWidget from './widgets/ActivityFeedWidget.svelte';
@ -60,4 +61,5 @@ export const widgetComponents: Record<WidgetType, Component> = {
'activity-feed': ActivityFeedWidget,
cycles: CyclesWidget,
'news-unread': NewsUnreadWidget,
'body-stats': BodyStatsWidget,
};

View file

@ -50,6 +50,7 @@ function registerLocale(lang: SupportedLocale) {
help,
cycles,
news,
body,
] = await Promise.all([
import(`./locales/apps/${lang}.json`),
import(`./locales/common/${lang}.json`),
@ -85,6 +86,7 @@ function registerLocale(lang: SupportedLocale) {
import(`./locales/help/${lang}.json`),
import(`./locales/cycles/${lang}.json`),
import(`./locales/news/${lang}.json`),
import(`./locales/body/${lang}.json`),
]);
return {
@ -122,6 +124,7 @@ function registerLocale(lang: SupportedLocale) {
help: help.default,
cycles: cycles.default,
news: news.default,
body: body.default,
};
});
}

View file

@ -0,0 +1,38 @@
{
"title": "Body",
"subtitle": "Training & Körper in einem Modul",
"activeWorkout": "Aktives Workout",
"finish": "Beenden",
"readyToTrain": "Bereit für ein Workout?",
"startHint": "Starte eine neue Session und logge deine Sätze",
"startWorkout": "Workout starten",
"weight": "Gewicht",
"dailyCheck": "Heute",
"recent": "Letzte Workouts",
"noWorkouts": "Noch keine Sessions",
"log": "Loggen",
"measurement": {
"weight": "Gewicht",
"bodyfat": "Körperfett",
"muscle": "Muskelmasse",
"chest": "Brust",
"waist": "Taille",
"hips": "Hüfte",
"thigh": "Oberschenkel",
"arm": "Arm",
"calf": "Wade",
"neck": "Hals"
},
"check": {
"energy": "Energie",
"sleep": "Schlaf",
"soreness": "Muskelkater",
"mood": "Stimmung"
},
"phase": {
"cut": "Cut",
"bulk": "Bulk",
"maintenance": "Maintenance",
"recomp": "Recomp"
}
}

View file

@ -0,0 +1,38 @@
{
"title": "Body",
"subtitle": "Training & body tracking in one module",
"activeWorkout": "Active workout",
"finish": "Finish",
"readyToTrain": "Ready to train?",
"startHint": "Start a new session and log your sets",
"startWorkout": "Start workout",
"weight": "Weight",
"dailyCheck": "Today",
"recent": "Recent workouts",
"noWorkouts": "No sessions yet",
"log": "Log",
"measurement": {
"weight": "Weight",
"bodyfat": "Body fat",
"muscle": "Muscle mass",
"chest": "Chest",
"waist": "Waist",
"hips": "Hips",
"thigh": "Thigh",
"arm": "Arm",
"calf": "Calf",
"neck": "Neck"
},
"check": {
"energy": "Energy",
"sleep": "Sleep",
"soreness": "Soreness",
"mood": "Mood"
},
"phase": {
"cut": "Cut",
"bulk": "Bulk",
"maintenance": "Maintenance",
"recomp": "Recomp"
}
}

View file

@ -0,0 +1,38 @@
{
"title": "Body",
"subtitle": "Entrenamiento y cuerpo en un solo módulo",
"activeWorkout": "Entrenamiento activo",
"finish": "Terminar",
"readyToTrain": "¿Listo para entrenar?",
"startHint": "Inicia una nueva sesión y registra tus series",
"startWorkout": "Iniciar entrenamiento",
"weight": "Peso",
"dailyCheck": "Hoy",
"recent": "Últimos entrenamientos",
"noWorkouts": "Aún sin sesiones",
"log": "Registrar",
"measurement": {
"weight": "Peso",
"bodyfat": "Grasa corporal",
"muscle": "Masa muscular",
"chest": "Pecho",
"waist": "Cintura",
"hips": "Caderas",
"thigh": "Muslo",
"arm": "Brazo",
"calf": "Pantorrilla",
"neck": "Cuello"
},
"check": {
"energy": "Energía",
"sleep": "Sueño",
"soreness": "Agujetas",
"mood": "Estado de ánimo"
},
"phase": {
"cut": "Definición",
"bulk": "Volumen",
"maintenance": "Mantenimiento",
"recomp": "Recomposición"
}
}

View file

@ -0,0 +1,38 @@
{
"title": "Body",
"subtitle": "Entraînement et corps dans un seul module",
"activeWorkout": "Séance en cours",
"finish": "Terminer",
"readyToTrain": "Prêt à t'entraîner ?",
"startHint": "Démarre une nouvelle séance et enregistre tes séries",
"startWorkout": "Démarrer la séance",
"weight": "Poids",
"dailyCheck": "Aujourd'hui",
"recent": "Dernières séances",
"noWorkouts": "Pas encore de séance",
"log": "Enregistrer",
"measurement": {
"weight": "Poids",
"bodyfat": "Masse grasse",
"muscle": "Masse musculaire",
"chest": "Poitrine",
"waist": "Tour de taille",
"hips": "Hanches",
"thigh": "Cuisse",
"arm": "Bras",
"calf": "Mollet",
"neck": "Cou"
},
"check": {
"energy": "Énergie",
"sleep": "Sommeil",
"soreness": "Courbatures",
"mood": "Humeur"
},
"phase": {
"cut": "Sèche",
"bulk": "Prise de masse",
"maintenance": "Maintenance",
"recomp": "Recomposition"
}
}

View file

@ -0,0 +1,38 @@
{
"title": "Body",
"subtitle": "Allenamento e corpo in un solo modulo",
"activeWorkout": "Allenamento attivo",
"finish": "Termina",
"readyToTrain": "Pronto ad allenarti?",
"startHint": "Inizia una nuova sessione e registra le serie",
"startWorkout": "Inizia allenamento",
"weight": "Peso",
"dailyCheck": "Oggi",
"recent": "Ultimi allenamenti",
"noWorkouts": "Nessuna sessione",
"log": "Registra",
"measurement": {
"weight": "Peso",
"bodyfat": "Grasso corporeo",
"muscle": "Massa muscolare",
"chest": "Petto",
"waist": "Vita",
"hips": "Fianchi",
"thigh": "Coscia",
"arm": "Braccio",
"calf": "Polpaccio",
"neck": "Collo"
},
"check": {
"energy": "Energia",
"sleep": "Sonno",
"soreness": "Dolori muscolari",
"mood": "Umore"
},
"phase": {
"cut": "Definizione",
"bulk": "Massa",
"maintenance": "Mantenimento",
"recomp": "Ricomposizione"
}
}

View file

@ -154,6 +154,10 @@
"news_unread": {
"title": "News",
"description": "Top-Artikel aus deinem kuratierten Feed"
},
"body_stats": {
"title": "Body",
"description": "Aktuelles Gewicht und Trainings-Status"
}
}
}

View file

@ -154,6 +154,10 @@
"news_unread": {
"title": "News",
"description": "Top articles from your curated feed"
},
"body_stats": {
"title": "Body",
"description": "Latest weight and training status"
}
}
}

View file

@ -149,6 +149,10 @@
"news_unread": {
"title": "Noticias",
"description": "Artículos destacados de tu feed curado"
},
"body_stats": {
"title": "Body",
"description": "Peso actual y estado del entrenamiento"
}
}
}

View file

@ -149,6 +149,10 @@
"news_unread": {
"title": "Actualités",
"description": "Articles phares de ton fil personnalisé"
},
"body_stats": {
"title": "Body",
"description": "Poids actuel et statut de l'entraînement"
}
}
}

View file

@ -149,6 +149,10 @@
"news_unread": {
"title": "Notizie",
"description": "Articoli in evidenza dal tuo feed curato"
},
"body_stats": {
"title": "Body",
"description": "Peso attuale e stato dell'allenamento"
}
}
}

View file

@ -0,0 +1,216 @@
<!--
Body — main module view.
Composes the actual feature components: workout logger when a session
is running, otherwise a "start" prompt; weight chart with quick-log;
daily energy/sleep/soreness/mood card; recent workouts.
-->
<script lang="ts">
import { getContext } from 'svelte';
import { _ } from 'svelte-i18n';
import type { Observable } from 'dexie';
import type {
BodyExercise,
BodyWorkout,
BodySet,
BodyMeasurement,
BodyCheck,
BodyPhase,
} from './types';
import { getActiveWorkout, getActivePhase } from './queries';
import { bodyStore } from './stores/body.svelte';
import WorkoutLogger from './components/WorkoutLogger.svelte';
import MeasurementForm from './components/MeasurementForm.svelte';
import WeightChart from './components/WeightChart.svelte';
import DailyCheckCard from './components/DailyCheckCard.svelte';
import RecentWorkouts from './components/RecentWorkouts.svelte';
const exercises$: Observable<BodyExercise[]> = getContext('bodyExercises');
const workouts$: Observable<BodyWorkout[]> = getContext('bodyWorkouts');
const sets$: Observable<BodySet[]> = getContext('bodySets');
const measurements$: Observable<BodyMeasurement[]> = getContext('bodyMeasurements');
const checks$: Observable<BodyCheck[]> = getContext('bodyChecks');
const phases$: Observable<BodyPhase[]> = getContext('bodyPhases');
let exercises = $state<BodyExercise[]>([]);
let workouts = $state<BodyWorkout[]>([]);
let sets = $state<BodySet[]>([]);
let measurements = $state<BodyMeasurement[]>([]);
let checks = $state<BodyCheck[]>([]);
let phases = $state<BodyPhase[]>([]);
$effect(() => {
const sub = exercises$.subscribe((v) => (exercises = v));
return () => sub.unsubscribe();
});
$effect(() => {
const sub = workouts$.subscribe((v) => (workouts = v));
return () => sub.unsubscribe();
});
$effect(() => {
const sub = sets$.subscribe((v) => (sets = v));
return () => sub.unsubscribe();
});
$effect(() => {
const sub = measurements$.subscribe((v) => (measurements = v));
return () => sub.unsubscribe();
});
$effect(() => {
const sub = checks$.subscribe((v) => (checks = v));
return () => sub.unsubscribe();
});
$effect(() => {
const sub = phases$.subscribe((v) => (phases = v));
return () => sub.unsubscribe();
});
let activeWorkout = $derived(getActiveWorkout(workouts));
let activePhase = $derived(getActivePhase(phases));
let activeExercises = $derived(exercises.filter((e) => !e.isArchived));
async function startWorkout() {
await bodyStore.startWorkout({});
}
</script>
<svelte:head>
<title>{$_('body.title', { default: 'Body' })} - Mana</title>
</svelte:head>
<div class="body-page">
<header class="body-header">
<div>
<h1>{$_('body.title', { default: 'Body' })}</h1>
<p class="subtitle">
{$_('body.subtitle', { default: 'Training & Körper in einem Modul' })}
</p>
</div>
{#if activePhase}
<div class="phase-pill" data-kind={activePhase.kind}>
{$_(`body.phase.${activePhase.kind}`, { default: activePhase.kind })}
</div>
{/if}
</header>
<section class="card workout-card">
{#if activeWorkout}
<WorkoutLogger workout={activeWorkout} {sets} exercises={activeExercises} />
{:else}
<div class="start-row">
<div>
<h2>{$_('body.readyToTrain', { default: 'Bereit für ein Workout?' })}</h2>
<p class="muted">
{$_('body.startHint', { default: 'Starte eine neue Session und logge deine Sätze' })}
</p>
</div>
<button type="button" onclick={startWorkout}>
{$_('body.startWorkout', { default: 'Workout starten' })}
</button>
</div>
{/if}
</section>
<section class="card">
<h2>{$_('body.weight', { default: 'Gewicht' })}</h2>
<WeightChart {measurements} />
<MeasurementForm />
</section>
<section class="card">
<h2>{$_('body.dailyCheck', { default: 'Heute' })}</h2>
<DailyCheckCard {checks} />
</section>
<section class="card">
<h2>{$_('body.recent', { default: 'Letzte Workouts' })}</h2>
<RecentWorkouts {workouts} {sets} />
</section>
</div>
<style>
.body-page {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 0 1rem;
max-width: 720px;
}
.body-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
}
.body-header h1 {
font-size: 1.5rem;
font-weight: 700;
color: hsl(var(--color-foreground));
}
.subtitle {
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
margin-top: 0.125rem;
}
.phase-pill {
padding: 0.25rem 0.75rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
background: hsl(var(--color-muted));
color: hsl(var(--color-foreground));
}
.phase-pill[data-kind='cut'] {
background: hsl(0 84% 60% / 0.15);
color: hsl(0 84% 50%);
}
.phase-pill[data-kind='bulk'] {
background: hsl(142 71% 45% / 0.15);
color: hsl(142 71% 38%);
}
.phase-pill[data-kind='maintenance'] {
background: hsl(217 91% 60% / 0.15);
color: hsl(217 91% 50%);
}
.card {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem;
border-radius: 0.75rem;
background: hsl(var(--color-card));
border: 1px solid hsl(var(--color-border));
}
.card h2 {
font-size: 0.875rem;
font-weight: 600;
color: hsl(var(--color-foreground));
}
.workout-card {
gap: 0.875rem;
}
.start-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.start-row h2 {
font-size: 1rem;
}
.muted {
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
button {
padding: 0.625rem 1rem;
border-radius: 0.5rem;
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
font-size: 0.875rem;
font-weight: 500;
border: none;
cursor: pointer;
}
</style>

View file

@ -0,0 +1,96 @@
<!--
DailyCheckCard — 1-5 button rows for the daily energy / sleep /
soreness / mood self-rating. Upserts to bodyChecks per day, so
re-tapping a button overwrites that field for today.
-->
<script lang="ts">
import { _ } from 'svelte-i18n';
import type { BodyCheck } from '../types';
import { bodyStore } from '../stores/body.svelte';
import { todayDateStr } from '../queries';
interface Props {
checks: BodyCheck[];
}
const { checks }: Props = $props();
let today = todayDateStr();
let todayCheck = $derived(checks.find((c) => c.date === today) ?? null);
const FIELDS = [
{ key: 'energy', label: 'Energie' },
{ key: 'sleep', label: 'Schlaf' },
{ key: 'soreness', label: 'Muskelkater' },
{ key: 'mood', label: 'Stimmung' },
] as const;
type CheckField = (typeof FIELDS)[number]['key'];
async function rate(field: CheckField, value: number) {
await bodyStore.upsertCheck({ [field]: value });
}
function valueOf(field: CheckField): number | null {
return todayCheck ? (todayCheck[field] ?? null) : null;
}
</script>
<div class="check-card">
{#each FIELDS as field (field.key)}
<div class="check-row">
<div class="check-label">{$_(`body.check.${field.key}`, { default: field.label })}</div>
<div class="check-buttons">
{#each [1, 2, 3, 4, 5] as n (n)}
{@const active = valueOf(field.key) === n}
<button
type="button"
class="dot"
class:active
onclick={() => rate(field.key, n)}
aria-label={`${field.label} ${n}`}
>
{n}
</button>
{/each}
</div>
</div>
{/each}
</div>
<style>
.check-card {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.check-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.check-label {
font-size: 0.8125rem;
color: hsl(var(--color-foreground));
}
.check-buttons {
display: flex;
gap: 0.25rem;
}
.dot {
width: 1.75rem;
height: 1.75rem;
border-radius: 50%;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-background));
color: hsl(var(--color-muted-foreground));
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
}
.dot.active {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
border-color: hsl(var(--color-primary));
}
</style>

View file

@ -0,0 +1,111 @@
<!--
MeasurementForm — quick-log a body measurement.
Defaults to weight in kg, which is what 95% of users actually log.
Type picker for the rarer fields (body fat, circumferences). The
unit defaults follow the type: weight/muscle → kg, bodyfat → %,
everything else → cm.
-->
<script lang="ts">
import { _ } from 'svelte-i18n';
import { bodyStore } from '../stores/body.svelte';
import { MEASUREMENT_TYPES } from '../types';
import type { MeasurementType } from '../types';
let type = $state<MeasurementType>('weight');
let value = $state<number | null>(null);
let saving = $state(false);
function unitFor(t: MeasurementType): 'kg' | 'cm' | 'percent' {
if (t === 'weight' || t === 'muscle') return 'kg';
if (t === 'bodyfat') return 'percent';
return 'cm';
}
let unit = $derived(unitFor(type));
async function submit() {
if (value === null || !Number.isFinite(value) || value <= 0) return;
saving = true;
try {
await bodyStore.logMeasurement({ type, value, unit });
value = null;
} finally {
saving = false;
}
}
</script>
<form
class="measurement-form"
onsubmit={(e) => {
e.preventDefault();
submit();
}}
>
<select bind:value={type}>
{#each MEASUREMENT_TYPES as t (t)}
<option value={t}>{$_(`body.measurement.${t}`, { default: t })}</option>
{/each}
</select>
<div class="value-row">
<input type="number" step="0.1" placeholder="0" bind:value />
<span class="unit">{unit === 'percent' ? '%' : unit}</span>
</div>
<button type="submit" disabled={saving || value === null}>
{$_('body.log', { default: 'Loggen' })}
</button>
</form>
<style>
.measurement-form {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
align-items: center;
}
select {
flex: 1 1 7rem;
padding: 0.5rem 0.625rem;
border-radius: 0.5rem;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-background));
color: hsl(var(--color-foreground));
font-size: 0.875rem;
}
.value-row {
display: flex;
align-items: center;
gap: 0.25rem;
}
input {
width: 5rem;
padding: 0.5rem 0.625rem;
border-radius: 0.5rem;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-background));
color: hsl(var(--color-foreground));
font-variant-numeric: tabular-nums;
}
.unit {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
min-width: 1.5rem;
}
button {
padding: 0.5rem 0.875rem;
border-radius: 0.5rem;
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
font-size: 0.875rem;
font-weight: 500;
border: none;
cursor: pointer;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>

View file

@ -0,0 +1,84 @@
<!--
RecentWorkouts — list of recent finished sessions with set count,
total volume and duration. Pure read-only summary view.
-->
<script lang="ts">
import { _ } from 'svelte-i18n';
import type { BodySet, BodyWorkout } from '../types';
import { getWorkoutVolume } from '../queries';
interface Props {
workouts: BodyWorkout[];
sets: BodySet[];
limit?: number;
}
const { workouts, sets, limit = 5 }: Props = $props();
let recent = $derived(workouts.filter((w) => w.endedAt !== null).slice(0, limit));
function setsForWorkout(id: string): BodySet[] {
return sets.filter((s) => s.workoutId === id);
}
function durationMin(w: BodyWorkout): number | null {
if (!w.endedAt) return null;
return Math.round((new Date(w.endedAt).getTime() - new Date(w.startedAt).getTime()) / 60000);
}
function fmtDate(iso: string): string {
return new Date(iso).toLocaleDateString();
}
</script>
{#if recent.length === 0}
<p class="empty">{$_('body.noWorkouts', { default: 'Noch keine Sessions' })}</p>
{:else}
<ul class="list">
{#each recent as w (w.id)}
{@const ws = setsForWorkout(w.id)}
{@const vol = getWorkoutVolume(ws)}
{@const dur = durationMin(w)}
<li>
<div class="title">{w.title ?? 'Workout'}</div>
<div class="meta">
<span>{fmtDate(w.startedAt)}</span>
<span>· {ws.length} sets</span>
{#if vol > 0}
<span>· {vol} kg vol</span>
{/if}
{#if dur !== null}
<span>· {dur}min</span>
{/if}
</div>
</li>
{/each}
</ul>
{/if}
<style>
.list {
display: flex;
flex-direction: column;
gap: 0.5rem;
list-style: none;
padding: 0;
margin: 0;
}
.title {
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--color-foreground));
}
.meta {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
display: flex;
gap: 0.375rem;
flex-wrap: wrap;
font-variant-numeric: tabular-nums;
}
.empty {
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
</style>

View file

@ -0,0 +1,147 @@
<!--
SetRow — single editable set inside an active workout.
Inline weight + reps inputs, RPE picker, warmup toggle, delete button.
-->
<script lang="ts">
import type { BodySet } from '../types';
import { bodyStore } from '../stores/body.svelte';
interface Props {
set: BodySet;
index: number;
}
const { set, index }: Props = $props();
// Local editable state mirrors the prop. Re-syncs whenever the parent
// passes in a new set object — typically because the previous commit
// re-emitted the row through liveQuery.
let weight = $state(0);
let reps = $state(0);
$effect(() => {
weight = set.weight;
reps = set.reps;
});
let weightDirty = $derived(weight !== set.weight);
let repsDirty = $derived(reps !== set.reps);
async function commit() {
if (!weightDirty && !repsDirty) return;
await bodyStore.updateSet(set.id, { weight, reps });
}
async function toggleWarmup() {
await bodyStore.updateSet(set.id, { isWarmup: !set.isWarmup });
}
async function remove() {
await bodyStore.deleteSet(set.id);
}
</script>
<div class="set-row" class:warmup={set.isWarmup}>
<button
type="button"
class="warmup-toggle"
onclick={toggleWarmup}
title={set.isWarmup ? 'Aufwärmsatz' : 'Arbeitssatz'}
>
{set.isWarmup ? 'W' : index + 1}
</button>
<label class="field">
<span class="label">kg</span>
<input
type="number"
step="0.5"
bind:value={weight}
onblur={commit}
onkeydown={(e) => e.key === 'Enter' && commit()}
/>
</label>
<label class="field">
<span class="label">reps</span>
<input
type="number"
step="1"
min="0"
bind:value={reps}
onblur={commit}
onkeydown={(e) => e.key === 'Enter' && commit()}
/>
</label>
{#if set.rpe !== null}
<span class="rpe">RPE {set.rpe}</span>
{/if}
<button type="button" class="remove" onclick={remove} aria-label="Set löschen">×</button>
</div>
<style>
.set-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.5rem;
border-radius: 0.5rem;
background: hsl(var(--color-background));
border: 1px solid hsl(var(--color-border));
}
.set-row.warmup {
opacity: 0.65;
}
.warmup-toggle {
width: 1.75rem;
height: 1.75rem;
border-radius: 0.375rem;
background: hsl(var(--color-muted));
color: hsl(var(--color-foreground));
font-size: 0.75rem;
font-weight: 600;
border: none;
cursor: pointer;
flex-shrink: 0;
}
.field {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.field .label {
font-size: 0.625rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: hsl(var(--color-muted-foreground));
}
.field input {
width: 4rem;
padding: 0.25rem 0.375rem;
border-radius: 0.25rem;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-background));
color: hsl(var(--color-foreground));
font-size: 0.875rem;
font-variant-numeric: tabular-nums;
}
.rpe {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.remove {
margin-left: auto;
width: 1.5rem;
height: 1.5rem;
border: none;
background: transparent;
color: hsl(var(--color-muted-foreground));
font-size: 1rem;
cursor: pointer;
border-radius: 0.25rem;
}
.remove:hover {
color: hsl(var(--color-destructive, 0 84% 60%));
background: hsl(var(--color-muted));
}
</style>

View file

@ -0,0 +1,136 @@
<!--
WeightChart — minimal SVG line chart for a single measurement type
(defaults to weight) over the most recent N entries.
Pure SVG: no chart library dependency. The y-axis auto-scales to
the value range with a small padding so flat-line periods don't
collapse to a single horizontal line at the top of the box.
-->
<script lang="ts">
import type { BodyMeasurement, MeasurementType } from '../types';
interface Props {
measurements: BodyMeasurement[];
type?: MeasurementType;
limit?: number;
height?: number;
}
const { measurements, type = 'weight', limit = 30, height = 100 }: Props = $props();
let series = $derived(
measurements
.filter((m) => m.type === type)
.sort((a, b) => a.date.localeCompare(b.date))
.slice(-limit)
);
let extent = $derived.by(() => {
if (series.length === 0) return { min: 0, max: 1 };
const values = series.map((m) => m.value);
const min = Math.min(...values);
const max = Math.max(...values);
const pad = Math.max((max - min) * 0.15, 0.5);
return { min: min - pad, max: max + pad };
});
const width = 320;
const padX = 8;
const padY = 8;
let path = $derived.by(() => {
if (series.length < 2) return '';
const range = extent.max - extent.min || 1;
const stepX = (width - padX * 2) / (series.length - 1);
return series
.map((m, i) => {
const x = padX + i * stepX;
const y = padY + (height - padY * 2) * (1 - (m.value - extent.min) / range);
return `${i === 0 ? 'M' : 'L'} ${x.toFixed(1)} ${y.toFixed(1)}`;
})
.join(' ');
});
let latest = $derived(series[series.length - 1]);
let first = $derived(series[0]);
let delta = $derived(latest && first ? latest.value - first.value : 0);
</script>
<div class="chart">
{#if series.length === 0}
<p class="empty">Noch keine Daten</p>
{:else}
<div class="header">
<div class="latest">
{latest.value} <span class="unit">{latest.unit === 'percent' ? '%' : latest.unit}</span>
</div>
{#if series.length > 1}
<div class="delta" class:positive={delta > 0} class:negative={delta < 0}>
{delta > 0 ? '+' : ''}{delta.toFixed(1)}
</div>
{/if}
</div>
<svg viewBox="0 0 {width} {height}" preserveAspectRatio="none">
<path
d={path}
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
{#each series as m, i (m.id)}
{@const range = extent.max - extent.min || 1}
{@const stepX = (width - padX * 2) / Math.max(series.length - 1, 1)}
{@const x = padX + i * stepX}
{@const y = padY + (height - padY * 2) * (1 - (m.value - extent.min) / range)}
<circle cx={x} cy={y} r="2" fill="currentColor" />
{/each}
</svg>
{/if}
</div>
<style>
.chart {
display: flex;
flex-direction: column;
gap: 0.5rem;
color: hsl(var(--color-primary));
}
.header {
display: flex;
align-items: baseline;
gap: 0.5rem;
}
.latest {
font-size: 1.25rem;
font-weight: 600;
color: hsl(var(--color-foreground));
font-variant-numeric: tabular-nums;
}
.unit {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
font-weight: 400;
}
.delta {
font-size: 0.75rem;
font-weight: 600;
color: hsl(var(--color-muted-foreground));
font-variant-numeric: tabular-nums;
}
.delta.positive {
color: hsl(142 71% 45%);
}
.delta.negative {
color: hsl(0 84% 60%);
}
svg {
width: 100%;
height: auto;
}
.empty {
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
</style>

View file

@ -0,0 +1,228 @@
<!--
WorkoutLogger — the active-workout console.
Shows the currently-running session, lets the user pick an exercise,
add new sets quickly (defaults pulled from the previous set on the
same exercise so progressive overload becomes one tap), and finish
the workout when done.
No active workout? The "Start Workout" call sits in the parent's
overview card, not here — this component just hides itself.
-->
<script lang="ts">
import { _ } from 'svelte-i18n';
import type { BodyExercise, BodySet, BodyWorkout } from '../types';
import { bodyStore } from '../stores/body.svelte';
import SetRow from './SetRow.svelte';
interface Props {
workout: BodyWorkout;
sets: BodySet[];
exercises: BodyExercise[];
}
const { workout, sets, exercises }: Props = $props();
let selectedExerciseId = $state<string>('');
let weight = $state<number>(0);
let reps = $state<number>(8);
let isWarmup = $state(false);
// Pre-fill from the most recent set of the selected exercise so the
// "next set" form starts at last week's working weight, not zero.
$effect(() => {
if (!selectedExerciseId) return;
const previous = [...sets]
.filter((s) => s.exerciseId === selectedExerciseId && !s.isWarmup)
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0];
if (previous) {
weight = previous.weight;
reps = previous.reps;
}
});
// Default to the first non-archived exercise on first render
$effect(() => {
if (!selectedExerciseId && exercises.length > 0) {
selectedExerciseId = exercises[0].id;
}
});
let setsForCurrent = $derived(
sets.filter((s) => s.workoutId === workout.id).sort((a, b) => a.order - b.order)
);
let setsByExercise = $derived.by(() => {
const map = new Map<string, BodySet[]>();
for (const s of setsForCurrent) {
const list = map.get(s.exerciseId) ?? [];
list.push(s);
map.set(s.exerciseId, list);
}
return map;
});
async function addSet() {
if (!selectedExerciseId || reps <= 0) return;
await bodyStore.logSet({
workoutId: workout.id,
exerciseId: selectedExerciseId,
reps,
weight,
weightUnit: 'kg',
isWarmup,
});
}
async function finish() {
await bodyStore.finishWorkout(workout.id);
}
function exerciseName(id: string): string {
return exercises.find((e) => e.id === id)?.name ?? id;
}
</script>
<div class="workout-logger">
<header>
<h3>{$_('body.activeWorkout', { default: 'Aktives Workout' })}</h3>
<button type="button" class="finish" onclick={finish}>
{$_('body.finish', { default: 'Beenden' })}
</button>
</header>
{#each [...setsByExercise.entries()] as [exerciseId, exSets] (exerciseId)}
<section class="exercise-block">
<h4>{exerciseName(exerciseId)}</h4>
<div class="set-list">
{#each exSets as set, i (set.id)}
<SetRow {set} index={i} />
{/each}
</div>
</section>
{/each}
<form
class="add-set"
onsubmit={(e) => {
e.preventDefault();
addSet();
}}
>
<select bind:value={selectedExerciseId}>
{#each exercises as ex (ex.id)}
<option value={ex.id}>{ex.name}</option>
{/each}
</select>
<label class="field">
<span class="label">kg</span>
<input type="number" step="0.5" bind:value={weight} />
</label>
<label class="field">
<span class="label">reps</span>
<input type="number" step="1" min="0" bind:value={reps} />
</label>
<label class="warmup-check">
<input type="checkbox" bind:checked={isWarmup} />
W
</label>
<button type="submit">+ Set</button>
</form>
</div>
<style>
.workout-logger {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
}
h3 {
font-size: 0.875rem;
font-weight: 600;
color: hsl(var(--color-foreground));
}
h4 {
font-size: 0.8125rem;
font-weight: 600;
color: hsl(var(--color-foreground));
margin-bottom: 0.375rem;
}
.exercise-block {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.set-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.add-set {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 0.5rem;
padding: 0.5rem;
border-radius: 0.5rem;
background: hsl(var(--color-muted) / 0.4);
}
.add-set select {
flex: 1 1 8rem;
padding: 0.375rem 0.5rem;
border-radius: 0.375rem;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-background));
color: hsl(var(--color-foreground));
font-size: 0.875rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.field .label {
font-size: 0.625rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: hsl(var(--color-muted-foreground));
}
.field input {
width: 4.5rem;
padding: 0.375rem 0.5rem;
border-radius: 0.375rem;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-background));
color: hsl(var(--color-foreground));
font-size: 0.875rem;
font-variant-numeric: tabular-nums;
}
.warmup-check {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
button {
padding: 0.5rem 0.875rem;
border-radius: 0.5rem;
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
font-size: 0.875rem;
font-weight: 500;
border: none;
cursor: pointer;
}
button.finish {
background: hsl(var(--color-muted));
color: hsl(var(--color-foreground));
}
</style>

View file

@ -0,0 +1,160 @@
<script lang="ts">
/**
* BodyStatsWidget — at-a-glance dashboard tile for the Body module.
*
* Surfaces the three things a user actually wants on the dashboard:
* 1. Latest weight + delta vs the previous reading
* 2. Active workout state (running session vs. idle)
* 3. Sets logged today + total volume
*
* Reads bodyMeasurements / bodyWorkouts / bodySets directly via liveQuery
* and decrypts in place — same pattern as NewsUnreadWidget.
*/
import { liveQuery } from 'dexie';
import { _ } from 'svelte-i18n';
import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto';
import {
toBodyMeasurement,
toBodyWorkout,
toBodySet,
getLatestWeight,
getActiveWorkout,
getWorkoutVolume,
} from '$lib/modules/body/queries';
import type {
LocalBodyMeasurement,
LocalBodyWorkout,
LocalBodySet,
BodyMeasurement,
BodyWorkout,
BodySet,
} from '$lib/modules/body/types';
let measurements = $state<BodyMeasurement[]>([]);
let workouts = $state<BodyWorkout[]>([]);
let sets = $state<BodySet[]>([]);
let loading = $state(true);
$effect(() => {
const sub = liveQuery(async () => {
const [mLocals, wLocals, sLocals] = await Promise.all([
db.table<LocalBodyMeasurement>('bodyMeasurements').toArray(),
db.table<LocalBodyWorkout>('bodyWorkouts').toArray(),
db.table<LocalBodySet>('bodySets').toArray(),
]);
const mVisible = mLocals.filter((m) => !m.deletedAt);
const wVisible = wLocals.filter((w) => !w.deletedAt);
const sVisible = sLocals.filter((s) => !s.deletedAt);
const [mDec, wDec, sDec] = await Promise.all([
decryptRecords('bodyMeasurements', mVisible),
decryptRecords('bodyWorkouts', wVisible),
decryptRecords('bodySets', sVisible),
]);
return {
measurements: mDec.map(toBodyMeasurement),
workouts: wDec.map(toBodyWorkout),
sets: sDec.map(toBodySet),
};
}).subscribe({
next: (v) => {
measurements = v.measurements;
workouts = v.workouts;
sets = v.sets;
loading = false;
},
error: () => {
loading = false;
},
});
return () => sub.unsubscribe();
});
let weights = $derived(
measurements.filter((m) => m.type === 'weight').sort((a, b) => b.date.localeCompare(a.date))
);
let latest = $derived(getLatestWeight(weights));
let previous = $derived(weights.length > 1 ? weights[1] : null);
let delta = $derived(latest && previous ? latest.value - previous.value : 0);
let activeWorkout = $derived(getActiveWorkout(workouts));
let today = new Date().toISOString().split('T')[0];
let todaySets = $derived(sets.filter((s) => s.createdAt.startsWith(today) && !s.isWarmup));
let todayVolume = $derived(getWorkoutVolume(todaySets));
</script>
<div>
<div class="mb-3 flex items-center justify-between">
<h3 class="flex items-center gap-2 text-lg font-semibold">
<span aria-hidden="true">💪</span>
{$_('body.title', { default: 'Body' })}
</h3>
<a href="/body" class="text-xs text-muted-foreground hover:text-foreground">Öffnen →</a>
</div>
{#if loading}
<div class="space-y-2">
<div class="h-12 animate-pulse rounded bg-surface-hover"></div>
<div class="h-8 animate-pulse rounded bg-surface-hover"></div>
</div>
{:else}
<div class="space-y-3">
<!-- Weight row -->
<div class="flex items-baseline justify-between">
<div class="text-xs uppercase tracking-wide text-muted-foreground">
{$_('body.weight', { default: 'Gewicht' })}
</div>
{#if latest}
<div class="flex items-baseline gap-2 tabular-nums">
<span class="text-2xl font-semibold">{latest.value}</span>
<span class="text-xs text-muted-foreground">{latest.unit}</span>
{#if previous}
<span
class="text-xs font-medium"
class:text-green-600={delta < 0}
class:text-red-600={delta > 0}
class:text-muted-foreground={delta === 0}
>
{delta > 0 ? '+' : ''}{delta.toFixed(1)}
</span>
{/if}
</div>
{:else}
<span class="text-xs text-muted-foreground"></span>
{/if}
</div>
<!-- Active workout / today summary -->
{#if activeWorkout}
<a
href="/body"
class="block rounded-lg bg-primary/10 p-3 transition-colors hover:bg-primary/15"
>
<div class="text-xs font-medium uppercase tracking-wide text-primary">
{$_('body.activeWorkout', { default: 'Aktives Workout' })}
</div>
<div class="mt-1 text-sm">
{todaySets.length} sets · {todayVolume} kg vol
</div>
</a>
{:else if todaySets.length > 0}
<div class="rounded-lg bg-surface-hover p-3">
<div class="text-xs uppercase tracking-wide text-muted-foreground">Heute</div>
<div class="mt-1 text-sm">{todaySets.length} sets · {todayVolume} kg vol</div>
</div>
{:else}
<a
href="/body"
class="block rounded-lg border border-dashed border-border p-3 text-center text-xs text-muted-foreground transition-colors hover:bg-surface-hover"
>
{$_('body.startWorkout', { default: 'Workout starten' })}
</a>
{/if}
</div>
{/if}
</div>

View file

@ -31,7 +31,8 @@ export type WidgetType =
| 'day-timeline' // TimeBlocks: chronological day timeline
| 'activity-feed' // TimeBlocks: recent activity across modules
| 'cycles' // Cycles: current phase + days until next period
| 'news-unread'; // News: latest unread curated articles
| 'news-unread' // News: latest unread curated articles
| 'body-stats'; // Body: latest weight + active workout summary
/**
* Widget size - maps to CSS Grid columns
@ -132,6 +133,7 @@ export interface WidgetMeta {
| 'nutriphi'
| 'planta'
| 'cycles'
| 'body'
| 'mana-auth';
}
@ -351,6 +353,15 @@ export const WIDGET_REGISTRY: WidgetMeta[] = [
defaultSize: 'small',
allowMultiple: false,
},
{
type: 'body-stats',
nameKey: 'dashboard.widgets.body_stats.title',
descriptionKey: 'dashboard.widgets.body_stats.description',
icon: '💪',
defaultSize: 'small',
allowMultiple: false,
requiredBackend: 'body',
},
];
/**

View file

@ -0,0 +1,25 @@
<script lang="ts">
import { setContext } from 'svelte';
import type { Snippet } from 'svelte';
import {
useAllBodyExercises,
useAllBodyRoutines,
useAllBodyWorkouts,
useAllBodySets,
useAllBodyMeasurements,
useAllBodyChecks,
useAllBodyPhases,
} from '$lib/modules/body/queries';
let { children }: { children: Snippet } = $props();
setContext('bodyExercises', useAllBodyExercises());
setContext('bodyRoutines', useAllBodyRoutines());
setContext('bodyWorkouts', useAllBodyWorkouts());
setContext('bodySets', useAllBodySets());
setContext('bodyMeasurements', useAllBodyMeasurements());
setContext('bodyChecks', useAllBodyChecks());
setContext('bodyPhases', useAllBodyPhases());
</script>
{@render children()}

View file

@ -0,0 +1,9 @@
<script lang="ts">
import ListView from '$lib/modules/body/ListView.svelte';
</script>
<svelte:head>
<title>Body - Mana</title>
</svelte:head>
<ListView />