mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
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:
parent
a412ccc6fb
commit
b2db42bb26
23 changed files with 1439 additions and 1 deletions
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
|||
38
apps/mana/apps/web/src/lib/i18n/locales/body/de.json
Normal file
38
apps/mana/apps/web/src/lib/i18n/locales/body/de.json
Normal 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"
|
||||
}
|
||||
}
|
||||
38
apps/mana/apps/web/src/lib/i18n/locales/body/en.json
Normal file
38
apps/mana/apps/web/src/lib/i18n/locales/body/en.json
Normal 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"
|
||||
}
|
||||
}
|
||||
38
apps/mana/apps/web/src/lib/i18n/locales/body/es.json
Normal file
38
apps/mana/apps/web/src/lib/i18n/locales/body/es.json
Normal 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"
|
||||
}
|
||||
}
|
||||
38
apps/mana/apps/web/src/lib/i18n/locales/body/fr.json
Normal file
38
apps/mana/apps/web/src/lib/i18n/locales/body/fr.json
Normal 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"
|
||||
}
|
||||
}
|
||||
38
apps/mana/apps/web/src/lib/i18n/locales/body/it.json
Normal file
38
apps/mana/apps/web/src/lib/i18n/locales/body/it.json
Normal 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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
216
apps/mana/apps/web/src/lib/modules/body/ListView.svelte
Normal file
216
apps/mana/apps/web/src/lib/modules/body/ListView.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
147
apps/mana/apps/web/src/lib/modules/body/components/SetRow.svelte
Normal file
147
apps/mana/apps/web/src/lib/modules/body/components/SetRow.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
25
apps/mana/apps/web/src/routes/(app)/body/+layout.svelte
Normal file
25
apps/mana/apps/web/src/routes/(app)/body/+layout.svelte
Normal 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()}
|
||||
9
apps/mana/apps/web/src/routes/(app)/body/+page.svelte
Normal file
9
apps/mana/apps/web/src/routes/(app)/body/+page.svelte
Normal 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 />
|
||||
Loading…
Add table
Add a link
Reference in a new issue