From b2f3b313bb6d17d5809778cc2228a043dc9941a3 Mon Sep 17 00:00:00 2001
From: Till JS
Date: Thu, 9 Apr 2026 16:55:51 +0200
Subject: [PATCH] feat(mana/web/body): exercise picker, routines, phases,
progression chart
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Five quick-win UI upgrades that take the body module from "skeleton
ListView" to "actually usable for daily training":
1. ExercisePicker modal (replaces the previous bare in
WorkoutLogger). Search by name, filter chips per muscle group,
inline create-new-exercise. The big win is the per-row "Last:
80kg × 8 · vor 3 Tagen" hint — progressive overload becomes
"look at the number, add 2.5kg" instead of digging through
workout history. Recently-trained exercises bubble to the top
so the picker matches what you actually do most days.
2. RoutineManager. Three seed routines added to BODY_GUEST_SEED
(Full Body Starter, Upper Day, Lower Day) so a fresh user has
a one-tap "start" path. Inline form to save custom routines as
chips of selected exercises. Archive button per routine; edit
is deferred. Routines start a workout via the existing
bodyStore.startWorkout({ routineId, title }) shape.
3. PhaseManager replaces the previously read-only header pill with
a clickable control. Three states: idle (start button), opening
(kind picker + start/target weight inputs), active (color-coded
summary card with end button). The auto-close-on-switch logic
was already in bodyStore.startPhase, so this is pure UI plumbing.
4. ExerciseProgressionChart. Same auto-scaled SVG approach as
WeightChart but plots best estimated 1RM (Epley) per day for
one exercise. Falls back to the most-recently-trained exercise
when no explicit id is pinned, so the chart is never empty on
first open.
5. New query helpers feeding the above: getLastSetByExercise,
getE1rmTimeline (collapses multiple working sets in one session
to the daily best so the chart isn't noisy), and a coarse
relativeDays formatter for the picker's "vor 3 Tagen" hints.
ListView re-composed: removed the dead phase-pill CSS, added
PhaseManager + RoutineManager + ExerciseProgressionChart sections,
left WorkoutLogger / WeightChart / DailyCheckCard / RecentWorkouts
in place. i18n keys for the new copy added to body/de.json and
body/en.json (it/fr/es fall back to the components' inline default
strings until translated).
Co-Authored-By: Claude Opus 4.6 (1M context)
---
.../web/src/lib/i18n/locales/body/de.json | 33 +-
.../web/src/lib/i18n/locales/body/en.json | 33 +-
.../web/src/lib/modules/body/ListView.svelte | 50 +--
.../web/src/lib/modules/body/collections.ts | 32 +-
.../body/components/ExercisePicker.svelte | 405 ++++++++++++++++++
.../ExerciseProgressionChart.svelte | 158 +++++++
.../body/components/PhaseManager.svelte | 243 +++++++++++
.../body/components/RoutineManager.svelte | 253 +++++++++++
.../body/components/WorkoutLogger.svelte | 52 ++-
.../apps/web/src/lib/modules/body/index.ts | 4 +
.../apps/web/src/lib/modules/body/queries.ts | 51 +++
11 files changed, 1276 insertions(+), 38 deletions(-)
create mode 100644 apps/mana/apps/web/src/lib/modules/body/components/ExercisePicker.svelte
create mode 100644 apps/mana/apps/web/src/lib/modules/body/components/ExerciseProgressionChart.svelte
create mode 100644 apps/mana/apps/web/src/lib/modules/body/components/PhaseManager.svelte
create mode 100644 apps/mana/apps/web/src/lib/modules/body/components/RoutineManager.svelte
diff --git a/apps/mana/apps/web/src/lib/i18n/locales/body/de.json b/apps/mana/apps/web/src/lib/i18n/locales/body/de.json
index 45ba89013..5331444b6 100644
--- a/apps/mana/apps/web/src/lib/i18n/locales/body/de.json
+++ b/apps/mana/apps/web/src/lib/i18n/locales/body/de.json
@@ -33,6 +33,37 @@
"cut": "Cut",
"bulk": "Bulk",
"maintenance": "Maintenance",
- "recomp": "Recomp"
+ "recomp": "Recomp",
+ "start": "Phase starten",
+ "startNew": "Phase starten",
+ "end": "Beenden"
+ },
+ "progression": "Progression",
+ "routines": {
+ "title": "Routinen",
+ "start": "Start",
+ "empty": "Noch keine Routinen"
+ },
+ "exercisePicker": {
+ "title": "Übung wählen",
+ "pick": "Übung wählen",
+ "search": "Suchen…",
+ "empty": "Nichts gefunden",
+ "create": "Neue Übung anlegen"
+ },
+ "muscle": {
+ "chest": "Brust",
+ "back": "Rücken",
+ "shoulders": "Schultern",
+ "biceps": "Bizeps",
+ "triceps": "Trizeps",
+ "forearms": "Unterarme",
+ "core": "Core",
+ "quads": "Quadrizeps",
+ "hamstrings": "Hamstrings",
+ "glutes": "Gesäß",
+ "calves": "Waden",
+ "cardio": "Cardio",
+ "fullbody": "Ganzkörper"
}
}
diff --git a/apps/mana/apps/web/src/lib/i18n/locales/body/en.json b/apps/mana/apps/web/src/lib/i18n/locales/body/en.json
index a557bbf7e..7741c5e2c 100644
--- a/apps/mana/apps/web/src/lib/i18n/locales/body/en.json
+++ b/apps/mana/apps/web/src/lib/i18n/locales/body/en.json
@@ -33,6 +33,37 @@
"cut": "Cut",
"bulk": "Bulk",
"maintenance": "Maintenance",
- "recomp": "Recomp"
+ "recomp": "Recomp",
+ "start": "Start phase",
+ "startNew": "Start phase",
+ "end": "End"
+ },
+ "progression": "Progression",
+ "routines": {
+ "title": "Routines",
+ "start": "Start",
+ "empty": "No routines yet"
+ },
+ "exercisePicker": {
+ "title": "Pick an exercise",
+ "pick": "Pick exercise",
+ "search": "Search…",
+ "empty": "Nothing found",
+ "create": "New exercise"
+ },
+ "muscle": {
+ "chest": "Chest",
+ "back": "Back",
+ "shoulders": "Shoulders",
+ "biceps": "Biceps",
+ "triceps": "Triceps",
+ "forearms": "Forearms",
+ "core": "Core",
+ "quads": "Quads",
+ "hamstrings": "Hamstrings",
+ "glutes": "Glutes",
+ "calves": "Calves",
+ "cardio": "Cardio",
+ "fullbody": "Full body"
}
}
diff --git a/apps/mana/apps/web/src/lib/modules/body/ListView.svelte b/apps/mana/apps/web/src/lib/modules/body/ListView.svelte
index 7794be11f..80cff3ad7 100644
--- a/apps/mana/apps/web/src/lib/modules/body/ListView.svelte
+++ b/apps/mana/apps/web/src/lib/modules/body/ListView.svelte
@@ -11,6 +11,7 @@
import type { Observable } from 'dexie';
import type {
BodyExercise,
+ BodyRoutine,
BodyWorkout,
BodySet,
BodyMeasurement,
@@ -24,8 +25,12 @@
import WeightChart from './components/WeightChart.svelte';
import DailyCheckCard from './components/DailyCheckCard.svelte';
import RecentWorkouts from './components/RecentWorkouts.svelte';
+ import RoutineManager from './components/RoutineManager.svelte';
+ import PhaseManager from './components/PhaseManager.svelte';
+ import ExerciseProgressionChart from './components/ExerciseProgressionChart.svelte';
const exercises$: Observable = getContext('bodyExercises');
+ const routines$: Observable = getContext('bodyRoutines');
const workouts$: Observable = getContext('bodyWorkouts');
const sets$: Observable = getContext('bodySets');
const measurements$: Observable = getContext('bodyMeasurements');
@@ -33,6 +38,7 @@
const phases$: Observable = getContext('bodyPhases');
let exercises = $state([]);
+ let routines = $state([]);
let workouts = $state([]);
let sets = $state([]);
let measurements = $state([]);
@@ -43,6 +49,10 @@
const sub = exercises$.subscribe((v) => (exercises = v));
return () => sub.unsubscribe();
});
+ $effect(() => {
+ const sub = routines$.subscribe((v) => (routines = v));
+ return () => sub.unsubscribe();
+ });
$effect(() => {
const sub = workouts$.subscribe((v) => (workouts = v));
return () => sub.unsubscribe();
@@ -85,13 +95,12 @@
{$_('body.subtitle', { default: 'Training & Körper in einem Modul' })}
- {#if activePhase}
-
- {$_(`body.phase.${activePhase.kind}`, { default: activePhase.kind })}
-
- {/if}
+
+
{#if activeWorkout}
@@ -110,6 +119,15 @@
{/if}
+
+
+
+ {$_('body.progression', { default: 'Progression' })}
+
+
+
{$_('body.weight', { default: 'Gewicht' })}
@@ -151,28 +169,6 @@
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;
diff --git a/apps/mana/apps/web/src/lib/modules/body/collections.ts b/apps/mana/apps/web/src/lib/modules/body/collections.ts
index 89133d32c..d03f9a2ab 100644
--- a/apps/mana/apps/web/src/lib/modules/body/collections.ts
+++ b/apps/mana/apps/web/src/lib/modules/body/collections.ts
@@ -92,7 +92,37 @@ export const BODY_GUEST_SEED = {
isPreset: true,
},
] satisfies LocalBodyExercise[],
- bodyRoutines: [] satisfies LocalBodyRoutine[],
+ bodyRoutines: [
+ {
+ id: 'body-routine-fullbody',
+ name: 'Full Body Starter',
+ description: 'Squat, Bench, Row — 3× pro Woche, der klassische Einstieg',
+ exerciseIds: ['body-exercise-squat', 'body-exercise-bench', 'body-exercise-row'],
+ order: 0,
+ isArchived: false,
+ },
+ {
+ id: 'body-routine-upper',
+ name: 'Upper Day',
+ description: 'Push + Pull für den Oberkörper',
+ exerciseIds: [
+ 'body-exercise-bench',
+ 'body-exercise-ohp',
+ 'body-exercise-row',
+ 'body-exercise-pullup',
+ ],
+ order: 1,
+ isArchived: false,
+ },
+ {
+ id: 'body-routine-lower',
+ name: 'Lower Day',
+ description: 'Beine und Posterior Chain',
+ exerciseIds: ['body-exercise-squat', 'body-exercise-deadlift'],
+ order: 2,
+ isArchived: false,
+ },
+ ] satisfies LocalBodyRoutine[],
bodyWorkouts: [] satisfies LocalBodyWorkout[],
bodySets: [] satisfies LocalBodySet[],
bodyMeasurements: [] satisfies LocalBodyMeasurement[],
diff --git a/apps/mana/apps/web/src/lib/modules/body/components/ExercisePicker.svelte b/apps/mana/apps/web/src/lib/modules/body/components/ExercisePicker.svelte
new file mode 100644
index 000000000..294bf5a0c
--- /dev/null
+++ b/apps/mana/apps/web/src/lib/modules/body/components/ExercisePicker.svelte
@@ -0,0 +1,405 @@
+
+
+
+
+
+
+
+
+ {$_('body.exercisePicker.title', { default: 'Übung wählen' })}
+ ×
+
+
+
+
+
+ (activeFilter = 'all')}
+ >
+ Alle
+
+ {#each MUSCLE_GROUPS as g (g)}
+ (activeFilter = g)}
+ >
+ {$_(`body.muscle.${g}`, { default: g })}
+
+ {/each}
+
+
+
+ {#if filtered.length === 0}
+
+ {$_('body.exercisePicker.empty', { default: 'Nichts gefunden' })}
+
+ {:else}
+
+ {#each filtered as ex (ex.id)}
+ {@const last = lastSets.get(ex.id)}
+
+ pick(ex.id)}>
+
+
{ex.name}
+
+ {$_(`body.muscle.${ex.muscleGroup}`, { default: ex.muscleGroup })}
+ · {ex.equipment}
+
+
+ {#if last}
+
+
{last.weight}kg × {last.reps}
+
{relativeDays(last.createdAt)}
+
+ {/if}
+
+
+ {/each}
+
+ {/if}
+
+
+
+ {#if creating}
+
+ {:else}
+ (creating = true)}>
+ + {$_('body.exercisePicker.create', { default: 'Neue Übung anlegen' })}
+
+ {/if}
+
+
+
+
+
diff --git a/apps/mana/apps/web/src/lib/modules/body/components/ExerciseProgressionChart.svelte b/apps/mana/apps/web/src/lib/modules/body/components/ExerciseProgressionChart.svelte
new file mode 100644
index 000000000..c8e370b5e
--- /dev/null
+++ b/apps/mana/apps/web/src/lib/modules/body/components/ExerciseProgressionChart.svelte
@@ -0,0 +1,158 @@
+
+
+
+
+ {#if !resolvedExercise || timeline.length === 0}
+
Noch keine Sets geloggt
+ {:else}
+
+
+
+
+ {#each timeline as p, i (p.date)}
+ {@const range = extent.max - extent.min || 1}
+ {@const stepX = (width - padX * 2) / Math.max(timeline.length - 1, 1)}
+ {@const x = padX + i * stepX}
+ {@const y = padY + (height - padY * 2) * (1 - (p.value - extent.min) / range)}
+
+ {/each}
+
+ {/if}
+
+
+
diff --git a/apps/mana/apps/web/src/lib/modules/body/components/PhaseManager.svelte b/apps/mana/apps/web/src/lib/modules/body/components/PhaseManager.svelte
new file mode 100644
index 000000000..4303287c2
--- /dev/null
+++ b/apps/mana/apps/web/src/lib/modules/body/components/PhaseManager.svelte
@@ -0,0 +1,243 @@
+
+
+
+
+ {#if activePhase}
+
+
+
+ {$_(`body.phase.${activePhase.kind}`, { default: activePhase.kind })}
+
+
+ seit {activePhase.startDate}
+ {#if activePhase.targetWeight}
+ · Ziel: {activePhase.targetWeight}kg
+ {/if}
+
+
+
+ {$_('body.phase.end', { default: 'Beenden' })}
+
+
+ {:else if opening}
+
+ {:else}
+
(opening = true)}>
+ + {$_('body.phase.startNew', { default: 'Phase starten' })}
+
+ {/if}
+
+
+
diff --git a/apps/mana/apps/web/src/lib/modules/body/components/RoutineManager.svelte b/apps/mana/apps/web/src/lib/modules/body/components/RoutineManager.svelte
new file mode 100644
index 000000000..84c1ecee3
--- /dev/null
+++ b/apps/mana/apps/web/src/lib/modules/body/components/RoutineManager.svelte
@@ -0,0 +1,253 @@
+
+
+
+
+
+
+ {#if creating}
+
+ {/if}
+
+ {#if activeRoutines.length === 0}
+
{$_('body.routines.empty', { default: 'Noch keine Routinen' })}
+ {:else}
+
+ {/if}
+
+
+
diff --git a/apps/mana/apps/web/src/lib/modules/body/components/WorkoutLogger.svelte b/apps/mana/apps/web/src/lib/modules/body/components/WorkoutLogger.svelte
index f39fe8408..523d274e7 100644
--- a/apps/mana/apps/web/src/lib/modules/body/components/WorkoutLogger.svelte
+++ b/apps/mana/apps/web/src/lib/modules/body/components/WorkoutLogger.svelte
@@ -13,7 +13,9 @@
import { _ } from 'svelte-i18n';
import type { BodyExercise, BodySet, BodyWorkout } from '../types';
import { bodyStore } from '../stores/body.svelte';
+ import { getLastSetByExercise, relativeDays } from '../queries';
import SetRow from './SetRow.svelte';
+ import ExercisePicker from './ExercisePicker.svelte';
interface Props {
workout: BodyWorkout;
@@ -26,6 +28,11 @@
let weight = $state(0);
let reps = $state(8);
let isWarmup = $state(false);
+ let pickerOpen = $state(false);
+
+ let lastSets = $derived(getLastSetByExercise(sets));
+ let selectedExercise = $derived(exercises.find((e) => e.id === selectedExerciseId) ?? null);
+ let lastForSelected = $derived(selectedExerciseId ? lastSets.get(selectedExerciseId) : null);
// 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.
@@ -108,11 +115,17 @@
addSet();
}}
>
-
- {#each exercises as ex (ex.id)}
- {ex.name}
- {/each}
-
+ (pickerOpen = true)}>
+
+ {selectedExercise?.name ?? $_('body.exercisePicker.pick', { default: 'Übung wählen' })}
+
+ {#if lastForSelected}
+
+ Last: {lastForSelected.weight}kg × {lastForSelected.reps}
+ · {relativeDays(lastForSelected.createdAt)}
+
+ {/if}
+
kg
@@ -133,6 +146,15 @@
+{#if pickerOpen}
+ (selectedExerciseId = id)}
+ onClose={() => (pickerOpen = false)}
+ />
+{/if}
+