feat(mana/web/body): exercise picker, routines, phases, progression chart

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 <select> 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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-09 16:55:51 +02:00
parent 77ad48972e
commit b2f3b313bb
11 changed files with 1276 additions and 38 deletions

View file

@ -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"
}
}

View file

@ -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"
}
}

View file

@ -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<BodyExercise[]> = getContext('bodyExercises');
const routines$: Observable<BodyRoutine[]> = getContext('bodyRoutines');
const workouts$: Observable<BodyWorkout[]> = getContext('bodyWorkouts');
const sets$: Observable<BodySet[]> = getContext('bodySets');
const measurements$: Observable<BodyMeasurement[]> = getContext('bodyMeasurements');
@ -33,6 +38,7 @@
const phases$: Observable<BodyPhase[]> = getContext('bodyPhases');
let exercises = $state<BodyExercise[]>([]);
let routines = $state<BodyRoutine[]>([]);
let workouts = $state<BodyWorkout[]>([]);
let sets = $state<BodySet[]>([]);
let measurements = $state<BodyMeasurement[]>([]);
@ -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' })}
</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">
<PhaseManager {activePhase} />
</section>
<section class="card workout-card">
{#if activeWorkout}
<WorkoutLogger workout={activeWorkout} {sets} exercises={activeExercises} />
@ -110,6 +119,15 @@
{/if}
</section>
<section class="card">
<RoutineManager {routines} {exercises} />
</section>
<section class="card">
<h2>{$_('body.progression', { default: 'Progression' })}</h2>
<ExerciseProgressionChart {sets} {exercises} />
</section>
<section class="card">
<h2>{$_('body.weight', { default: 'Gewicht' })}</h2>
<WeightChart {measurements} />
@ -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;

View file

@ -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[],

View file

@ -0,0 +1,405 @@
<!--
ExercisePicker — searchable modal for picking an exercise inside the
workout logger.
Three things this gives the user that the previous <select> couldn't:
1. Search by name (substring, case-insensitive)
2. Filter by muscle group via tap-able chips
3. Inline "last working set" hint per row — the highest-leverage
UX win in the whole module: progressive overload becomes
"look at the number, add 2.5kg".
Also lets the user create a new exercise inline without leaving the
picker. The new exercise is selected automatically so the parent
doesn't need to round-trip back through the list.
-->
<script lang="ts">
import { _ } from 'svelte-i18n';
import type { BodyExercise, BodySet, MuscleGroup } from '../types';
import { MUSCLE_GROUPS } from '../types';
import { getLastSetByExercise, relativeDays } from '../queries';
import { bodyStore } from '../stores/body.svelte';
interface Props {
exercises: BodyExercise[];
sets: BodySet[];
onPick: (exerciseId: string) => void;
onClose: () => void;
}
const { exercises, sets, onPick, onClose }: Props = $props();
let query = $state('');
let activeFilter = $state<MuscleGroup | 'all'>('all');
let creating = $state(false);
let newName = $state('');
let newMuscle = $state<MuscleGroup>('chest');
let lastSets = $derived(getLastSetByExercise(sets));
let filtered = $derived.by(() => {
const q = query.trim().toLowerCase();
return exercises
.filter((e) => !e.isArchived)
.filter((e) => activeFilter === 'all' || e.muscleGroup === activeFilter)
.filter((e) => q === '' || e.name.toLowerCase().includes(q))
.sort((a, b) => {
// Recently-used exercises bubble to the top — those with a
// known last-set come first, ordered by recency.
const la = lastSets.get(a.id);
const lb = lastSets.get(b.id);
if (la && lb) return lb.createdAt.localeCompare(la.createdAt);
if (la) return -1;
if (lb) return 1;
return a.name.localeCompare(b.name);
});
});
async function pick(id: string) {
onPick(id);
onClose();
}
async function createInline(e: Event) {
e.preventDefault();
const name = newName.trim();
if (!name) return;
const created = await bodyStore.createExercise({
name,
muscleGroup: newMuscle,
equipment: 'barbell',
});
newName = '';
creating = false;
// Auto-select the new exercise so the user can immediately log a set
// against it. The bodyExercises liveQuery will refresh the parent's
// list shortly after this resolves; until then we just hand back
// the id and trust the caller.
onPick(created.id);
onClose();
}
function backdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) onClose();
}
function handleKey(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
}
</script>
<svelte:window onkeydown={handleKey} />
<div
class="backdrop"
onclick={backdropClick}
onkeydown={handleKey}
role="dialog"
aria-modal="true"
aria-label="Übung auswählen"
tabindex="-1"
>
<div class="sheet">
<header>
<h2>{$_('body.exercisePicker.title', { default: 'Übung wählen' })}</h2>
<button type="button" class="close" onclick={onClose} aria-label="Schließen">×</button>
</header>
<input
class="search"
type="search"
placeholder={$_('body.exercisePicker.search', { default: 'Suchen…' })}
bind:value={query}
autofocus
/>
<div class="filters">
<button
type="button"
class="chip"
class:active={activeFilter === 'all'}
onclick={() => (activeFilter = 'all')}
>
Alle
</button>
{#each MUSCLE_GROUPS as g (g)}
<button
type="button"
class="chip"
class:active={activeFilter === g}
onclick={() => (activeFilter = g)}
>
{$_(`body.muscle.${g}`, { default: g })}
</button>
{/each}
</div>
<div class="results">
{#if filtered.length === 0}
<p class="empty">
{$_('body.exercisePicker.empty', { default: 'Nichts gefunden' })}
</p>
{:else}
<ul>
{#each filtered as ex (ex.id)}
{@const last = lastSets.get(ex.id)}
<li>
<button type="button" class="row" onclick={() => pick(ex.id)}>
<div class="row-main">
<div class="row-name">{ex.name}</div>
<div class="row-meta">
{$_(`body.muscle.${ex.muscleGroup}`, { default: ex.muscleGroup })}
· {ex.equipment}
</div>
</div>
{#if last}
<div class="row-last">
<div class="last-value">{last.weight}kg × {last.reps}</div>
<div class="last-when">{relativeDays(last.createdAt)}</div>
</div>
{/if}
</button>
</li>
{/each}
</ul>
{/if}
</div>
<footer>
{#if creating}
<form class="create-form" onsubmit={createInline}>
<input type="text" placeholder="Name" bind:value={newName} required />
<select bind:value={newMuscle}>
{#each MUSCLE_GROUPS as g (g)}
<option value={g}>{$_(`body.muscle.${g}`, { default: g })}</option>
{/each}
</select>
<button type="submit" class="primary">Anlegen</button>
<button type="button" onclick={() => (creating = false)}>Abbrechen</button>
</form>
{:else}
<button type="button" class="add" onclick={() => (creating = true)}>
+ {$_('body.exercisePicker.create', { default: 'Neue Übung anlegen' })}
</button>
{/if}
</footer>
</div>
</div>
<style>
.backdrop {
position: fixed;
inset: 0;
z-index: 50;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(2px);
display: flex;
align-items: flex-end;
justify-content: center;
padding: 0;
}
@media (min-width: 640px) {
.backdrop {
align-items: center;
padding: 1rem;
}
}
.sheet {
display: flex;
flex-direction: column;
width: 100%;
max-width: 32rem;
max-height: 90vh;
background: hsl(var(--color-card));
border: 1px solid hsl(var(--color-border));
border-radius: 1rem 1rem 0 0;
overflow: hidden;
}
@media (min-width: 640px) {
.sheet {
border-radius: 1rem;
}
}
header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.875rem 1rem;
border-bottom: 1px solid hsl(var(--color-border));
}
header h2 {
font-size: 0.95rem;
font-weight: 600;
color: hsl(var(--color-foreground));
}
.close {
width: 2rem;
height: 2rem;
border: none;
background: transparent;
color: hsl(var(--color-muted-foreground));
font-size: 1.5rem;
cursor: pointer;
border-radius: 0.375rem;
}
.close:hover {
background: hsl(var(--color-muted));
}
.search {
margin: 0.75rem 1rem 0;
padding: 0.625rem 0.875rem;
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;
}
.filters {
display: flex;
gap: 0.375rem;
padding: 0.625rem 1rem 0;
overflow-x: auto;
scrollbar-width: none;
}
.filters::-webkit-scrollbar {
display: none;
}
.chip {
padding: 0.3125rem 0.75rem;
border-radius: 999px;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-background));
color: hsl(var(--color-muted-foreground));
font-size: 0.75rem;
cursor: pointer;
flex-shrink: 0;
}
.chip.active {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
border-color: hsl(var(--color-primary));
}
.results {
flex: 1;
overflow-y: auto;
padding: 0.5rem 1rem;
}
.results ul {
display: flex;
flex-direction: column;
gap: 0.25rem;
list-style: none;
padding: 0;
margin: 0;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
width: 100%;
padding: 0.625rem 0.75rem;
border-radius: 0.5rem;
border: 1px solid transparent;
background: hsl(var(--color-background));
color: hsl(var(--color-foreground));
text-align: left;
cursor: pointer;
}
.row:hover {
background: hsl(var(--color-muted));
border-color: hsl(var(--color-border));
}
.row-main {
min-width: 0;
flex: 1;
}
.row-name {
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--color-foreground));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-meta {
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
text-transform: capitalize;
margin-top: 0.0625rem;
}
.row-last {
text-align: right;
flex-shrink: 0;
}
.last-value {
font-size: 0.8125rem;
font-weight: 600;
color: hsl(var(--color-primary));
font-variant-numeric: tabular-nums;
}
.last-when {
font-size: 0.625rem;
color: hsl(var(--color-muted-foreground));
}
.empty {
text-align: center;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
padding: 1.5rem 0;
}
footer {
padding: 0.75rem 1rem;
border-top: 1px solid hsl(var(--color-border));
background: hsl(var(--color-background));
}
.add {
width: 100%;
padding: 0.625rem 0.875rem;
border-radius: 0.5rem;
border: 1px dashed hsl(var(--color-border));
background: transparent;
color: hsl(var(--color-muted-foreground));
font-size: 0.8125rem;
cursor: pointer;
}
.add:hover {
color: hsl(var(--color-foreground));
border-color: hsl(var(--color-foreground));
}
.create-form {
display: flex;
gap: 0.375rem;
flex-wrap: wrap;
}
.create-form input {
flex: 1 1 8rem;
padding: 0.5rem 0.625rem;
border-radius: 0.375rem;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-background));
color: hsl(var(--color-foreground));
font-size: 0.8125rem;
}
.create-form select {
padding: 0.5rem 0.625rem;
border-radius: 0.375rem;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-background));
color: hsl(var(--color-foreground));
font-size: 0.8125rem;
}
.create-form button {
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-background));
color: hsl(var(--color-foreground));
font-size: 0.8125rem;
cursor: pointer;
}
.create-form button.primary {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
border-color: hsl(var(--color-primary));
}
</style>

View file

@ -0,0 +1,158 @@
<!--
ExerciseProgressionChart — best estimated 1RM (Epley) per day for one
exercise. Shares the auto-scaled SVG approach with WeightChart but
takes a sets array + exerciseId instead of measurements.
Optionally takes an exerciseSelector callback so a parent can render
a list of exercises and switch the chart between them. The default
picks the most-recently-trained exercise so the user always sees
something useful even on first open.
-->
<script lang="ts">
import type { BodyExercise, BodySet } from '../types';
import { getE1rmTimeline, getLastSetByExercise } from '../queries';
interface Props {
sets: BodySet[];
exercises: BodyExercise[];
exerciseId?: string;
height?: number;
}
const { sets, exercises, exerciseId, height = 120 }: Props = $props();
let lastSets = $derived(getLastSetByExercise(sets));
// If the parent doesn't pin one, fall back to the most recently
// trained exercise so the chart isn't blank on first open.
let resolvedId = $derived.by(() => {
if (exerciseId) return exerciseId;
const entries = [...lastSets.entries()].sort((a, b) =>
b[1].createdAt.localeCompare(a[1].createdAt)
);
return entries[0]?.[0] ?? '';
});
let resolvedExercise = $derived(exercises.find((e) => e.id === resolvedId) ?? null);
let timeline = $derived(resolvedId ? getE1rmTimeline(sets, resolvedId) : []);
let extent = $derived.by(() => {
if (timeline.length === 0) return { min: 0, max: 1 };
const values = timeline.map((p) => p.value);
const min = Math.min(...values);
const max = Math.max(...values);
const pad = Math.max((max - min) * 0.15, 1);
return { min: min - pad, max: max + pad };
});
const width = 320;
const padX = 8;
const padY = 8;
let path = $derived.by(() => {
if (timeline.length < 2) return '';
const range = extent.max - extent.min || 1;
const stepX = (width - padX * 2) / (timeline.length - 1);
return timeline
.map((p, i) => {
const x = padX + i * stepX;
const y = padY + (height - padY * 2) * (1 - (p.value - extent.min) / range);
return `${i === 0 ? 'M' : 'L'} ${x.toFixed(1)} ${y.toFixed(1)}`;
})
.join(' ');
});
let latest = $derived(timeline[timeline.length - 1]);
let first = $derived(timeline[0]);
let delta = $derived(latest && first ? latest.value - first.value : 0);
</script>
<div class="chart">
{#if !resolvedExercise || timeline.length === 0}
<p class="empty">Noch keine Sets geloggt</p>
{:else}
<div class="header">
<div class="title">{resolvedExercise.name}</div>
<div class="latest">
e1RM <span class="value">{latest.value}</span>
<span class="unit">kg</span>
{#if timeline.length > 1}
<span class="delta" class:positive={delta > 0} class:negative={delta < 0}>
{delta > 0 ? '+' : ''}{delta.toFixed(0)}
</span>
{/if}
</div>
</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 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)}
<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;
justify-content: space-between;
gap: 0.5rem;
}
.title {
font-size: 0.8125rem;
font-weight: 600;
color: hsl(var(--color-foreground));
}
.latest {
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
font-variant-numeric: tabular-nums;
}
.value {
font-size: 1rem;
font-weight: 600;
color: hsl(var(--color-foreground));
}
.unit {
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
}
.delta {
font-size: 0.75rem;
font-weight: 600;
margin-left: 0.25rem;
}
.delta.positive {
color: hsl(142 71% 45%);
}
.delta.negative {
color: hsl(0 84% 60%);
}
svg {
width: 100%;
height: auto;
}
.empty {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
</style>

View file

@ -0,0 +1,243 @@
<!--
PhaseManager — start/end the active Cut/Bulk/Maintenance/Recomp phase.
Replaces the previously read-only header pill with a clickable
control. Three states:
- No active phase: kind picker + "Start"
- Active phase: summary + "Beenden"
- Editing target: inline weight inputs
Phase auto-close on switch is handled by bodyStore.startPhase, so
this UI doesn't need to track the previous one explicitly.
-->
<script lang="ts">
import { _ } from 'svelte-i18n';
import type { BodyPhase, PhaseKind } from '../types';
import { bodyStore } from '../stores/body.svelte';
interface Props {
activePhase: BodyPhase | null;
}
const { activePhase }: Props = $props();
const KINDS: PhaseKind[] = ['cut', 'bulk', 'maintenance', 'recomp'];
let opening = $state(false);
let chosenKind = $state<PhaseKind>('cut');
let startWeight = $state<number | null>(null);
let targetWeight = $state<number | null>(null);
async function start() {
await bodyStore.startPhase({
kind: chosenKind,
startWeight,
targetWeight,
});
opening = false;
startWeight = null;
targetWeight = null;
}
async function end() {
if (activePhase) {
await bodyStore.endPhase(activePhase.id);
}
}
</script>
<div class="phase">
{#if activePhase}
<div class="active" data-kind={activePhase.kind}>
<div class="active-main">
<div class="kind">
{$_(`body.phase.${activePhase.kind}`, { default: activePhase.kind })}
</div>
<div class="meta">
seit {activePhase.startDate}
{#if activePhase.targetWeight}
· Ziel: {activePhase.targetWeight}kg
{/if}
</div>
</div>
<button type="button" class="end" onclick={end}>
{$_('body.phase.end', { default: 'Beenden' })}
</button>
</div>
{:else if opening}
<form class="form" onsubmit={(e) => (e.preventDefault(), start())}>
<div class="kind-picker">
{#each KINDS as k (k)}
<button
type="button"
class="kind-btn"
class:active={chosenKind === k}
data-kind={k}
onclick={() => (chosenKind = k)}
>
{$_(`body.phase.${k}`, { default: k })}
</button>
{/each}
</div>
<div class="weights">
<label>
<span>Start (kg)</span>
<input type="number" step="0.1" bind:value={startWeight} placeholder="—" />
</label>
<label>
<span>Ziel (kg)</span>
<input type="number" step="0.1" bind:value={targetWeight} placeholder="—" />
</label>
</div>
<div class="actions">
<button type="submit" class="primary">
{$_('body.phase.start', { default: 'Phase starten' })}
</button>
<button type="button" onclick={() => (opening = false)}>Abbrechen</button>
</div>
</form>
{:else}
<button type="button" class="open-btn" onclick={() => (opening = true)}>
+ {$_('body.phase.startNew', { default: 'Phase starten' })}
</button>
{/if}
</div>
<style>
.phase {
display: flex;
flex-direction: column;
}
.active {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.625rem;
padding: 0.625rem 0.75rem;
border-radius: 0.5rem;
background: hsl(var(--color-muted) / 0.4);
border: 1px solid hsl(var(--color-border));
}
.active[data-kind='cut'] {
background: hsl(0 84% 60% / 0.1);
border-color: hsl(0 84% 60% / 0.4);
}
.active[data-kind='bulk'] {
background: hsl(142 71% 45% / 0.1);
border-color: hsl(142 71% 45% / 0.4);
}
.active[data-kind='maintenance'] {
background: hsl(217 91% 60% / 0.1);
border-color: hsl(217 91% 60% / 0.4);
}
.kind {
font-size: 0.875rem;
font-weight: 600;
color: hsl(var(--color-foreground));
text-transform: uppercase;
letter-spacing: 0.05em;
}
.meta {
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
margin-top: 0.125rem;
}
.end,
.open-btn,
.actions button {
padding: 0.375rem 0.75rem;
border-radius: 0.375rem;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-background));
color: hsl(var(--color-foreground));
font-size: 0.75rem;
cursor: pointer;
}
.open-btn {
width: 100%;
padding: 0.625rem;
border-style: dashed;
color: hsl(var(--color-muted-foreground));
}
.open-btn:hover {
color: hsl(var(--color-foreground));
border-color: hsl(var(--color-foreground));
}
.form {
display: flex;
flex-direction: column;
gap: 0.625rem;
padding: 0.625rem;
border-radius: 0.5rem;
background: hsl(var(--color-muted) / 0.4);
}
.kind-picker {
display: flex;
gap: 0.25rem;
}
.kind-btn {
flex: 1;
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-muted-foreground));
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
cursor: pointer;
}
.kind-btn.active[data-kind='cut'] {
background: hsl(0 84% 60% / 0.15);
color: hsl(0 84% 50%);
border-color: hsl(0 84% 60% / 0.5);
}
.kind-btn.active[data-kind='bulk'] {
background: hsl(142 71% 45% / 0.15);
color: hsl(142 71% 38%);
border-color: hsl(142 71% 45% / 0.5);
}
.kind-btn.active[data-kind='maintenance'] {
background: hsl(217 91% 60% / 0.15);
color: hsl(217 91% 50%);
border-color: hsl(217 91% 60% / 0.5);
}
.kind-btn.active[data-kind='recomp'] {
background: hsl(280 60% 60% / 0.15);
color: hsl(280 60% 50%);
border-color: hsl(280 60% 60% / 0.5);
}
.weights {
display: flex;
gap: 0.5rem;
}
.weights label {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.weights span {
font-size: 0.625rem;
text-transform: uppercase;
color: hsl(var(--color-muted-foreground));
letter-spacing: 0.05em;
}
.weights input {
padding: 0.4375rem 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;
}
.actions {
display: flex;
gap: 0.5rem;
}
.actions .primary {
flex: 1;
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
border-color: hsl(var(--color-primary));
}
</style>

View file

@ -0,0 +1,253 @@
<!--
RoutineManager — list of saved routines (templates) with one-tap
start. Each routine card shows the exercise list and a "Start"
button that launches a new workout with the routine pre-attached.
Edit/delete are deferred to a future revision — the create-flow
here is a simple inline form (name + comma-separated exercise
picker via a multi-select) so users can save the routine they
just built without leaving the page.
-->
<script lang="ts">
import { _ } from 'svelte-i18n';
import type { BodyExercise, BodyRoutine } from '../types';
import { bodyStore } from '../stores/body.svelte';
interface Props {
routines: BodyRoutine[];
exercises: BodyExercise[];
onStarted?: () => void;
}
const { routines, exercises, onStarted }: Props = $props();
let creating = $state(false);
let newName = $state('');
let newSelected = $state<Set<string>>(new Set());
function exerciseName(id: string): string {
return exercises.find((e) => e.id === id)?.name ?? id;
}
function toggle(id: string) {
const next = new Set(newSelected);
if (next.has(id)) next.delete(id);
else next.add(id);
newSelected = next;
}
async function startFromRoutine(routine: BodyRoutine) {
await bodyStore.startWorkout({ routineId: routine.id, title: routine.name });
onStarted?.();
}
async function saveRoutine(e: Event) {
e.preventDefault();
const name = newName.trim();
if (!name || newSelected.size === 0) return;
await bodyStore.createRoutine({
name,
description: null,
exerciseIds: [...newSelected],
});
newName = '';
newSelected = new Set();
creating = false;
}
async function archive(id: string) {
await bodyStore.updateRoutine(id, { isArchived: true });
}
let activeRoutines = $derived(routines.filter((r) => !r.isArchived));
</script>
<div class="routines">
<div class="header">
<h3>{$_('body.routines.title', { default: 'Routinen' })}</h3>
<button type="button" class="link" onclick={() => (creating = !creating)}>
{creating ? 'Schließen' : '+ Neu'}
</button>
</div>
{#if creating}
<form class="create" onsubmit={saveRoutine}>
<input type="text" placeholder="Routine-Name" bind:value={newName} required />
<div class="picker-grid">
{#each exercises.filter((e) => !e.isArchived) as ex (ex.id)}
<label class="ex-chip" class:active={newSelected.has(ex.id)}>
<input
type="checkbox"
checked={newSelected.has(ex.id)}
onchange={() => toggle(ex.id)}
/>
{ex.name}
</label>
{/each}
</div>
<button type="submit" class="primary" disabled={!newName.trim() || newSelected.size === 0}>
Speichern ({newSelected.size})
</button>
</form>
{/if}
{#if activeRoutines.length === 0}
<p class="empty">{$_('body.routines.empty', { default: 'Noch keine Routinen' })}</p>
{:else}
<ul>
{#each activeRoutines as r (r.id)}
<li class="routine">
<div class="routine-main">
<div class="routine-name">{r.name}</div>
<div class="routine-exercises">
{r.exerciseIds.map(exerciseName).join(' · ')}
</div>
</div>
<div class="actions">
<button type="button" class="primary" onclick={() => startFromRoutine(r)}>
{$_('body.routines.start', { default: 'Start' })}
</button>
<button
type="button"
class="archive"
onclick={() => archive(r.id)}
aria-label="Archivieren">×</button
>
</div>
</li>
{/each}
</ul>
{/if}
</div>
<style>
.routines {
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
}
h3 {
font-size: 0.875rem;
font-weight: 600;
color: hsl(var(--color-foreground));
}
.link {
font-size: 0.75rem;
background: none;
border: none;
color: hsl(var(--color-primary));
cursor: pointer;
}
.create {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.625rem;
border-radius: 0.5rem;
background: hsl(var(--color-muted) / 0.4);
}
.create input[type='text'] {
padding: 0.5rem 0.625rem;
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;
}
.picker-grid {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.ex-chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.3125rem 0.625rem;
border-radius: 999px;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-background));
color: hsl(var(--color-muted-foreground));
font-size: 0.75rem;
cursor: pointer;
}
.ex-chip.active {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
border-color: hsl(var(--color-primary));
}
.ex-chip input {
display: none;
}
ul {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.routine {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.625rem;
padding: 0.625rem 0.75rem;
border-radius: 0.5rem;
background: hsl(var(--color-background));
border: 1px solid hsl(var(--color-border));
}
.routine-main {
min-width: 0;
flex: 1;
}
.routine-name {
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--color-foreground));
}
.routine-exercises {
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
margin-top: 0.125rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.actions {
display: flex;
gap: 0.25rem;
flex-shrink: 0;
}
button {
padding: 0.375rem 0.75rem;
border-radius: 0.375rem;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-background));
color: hsl(var(--color-foreground));
font-size: 0.75rem;
cursor: pointer;
}
button.primary {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
border-color: hsl(var(--color-primary));
}
button.primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.archive {
width: 1.75rem;
padding: 0.375rem;
color: hsl(var(--color-muted-foreground));
}
.empty {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
</style>

View file

@ -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<number>(0);
let reps = $state<number>(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();
}}
>
<select bind:value={selectedExerciseId}>
{#each exercises as ex (ex.id)}
<option value={ex.id}>{ex.name}</option>
{/each}
</select>
<button type="button" class="exercise-picker-btn" onclick={() => (pickerOpen = true)}>
<div class="picker-name">
{selectedExercise?.name ?? $_('body.exercisePicker.pick', { default: 'Übung wählen' })}
</div>
{#if lastForSelected}
<div class="picker-last">
Last: {lastForSelected.weight}kg × {lastForSelected.reps}
· {relativeDays(lastForSelected.createdAt)}
</div>
{/if}
</button>
<label class="field">
<span class="label">kg</span>
@ -133,6 +146,15 @@
</form>
</div>
{#if pickerOpen}
<ExercisePicker
{exercises}
{sets}
onPick={(id) => (selectedExerciseId = id)}
onClose={() => (pickerOpen = false)}
/>
{/if}
<style>
.workout-logger {
display: flex;
@ -174,14 +196,28 @@
border-radius: 0.5rem;
background: hsl(var(--color-muted) / 0.4);
}
.add-set select {
flex: 1 1 8rem;
padding: 0.375rem 0.5rem;
.exercise-picker-btn {
flex: 1 1 100%;
padding: 0.5rem 0.625rem;
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;
text-align: left;
cursor: pointer;
}
.exercise-picker-btn:hover {
border-color: hsl(var(--color-primary));
}
.picker-name {
font-weight: 500;
}
.picker-last {
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
margin-top: 0.125rem;
font-variant-numeric: tabular-nums;
}
.field {
display: flex;

View file

@ -30,7 +30,11 @@ export {
getActiveExercises,
getActiveWorkout,
getActivePhase,
getLastSetByExercise,
getE1rmTimeline,
relativeDays,
} from './queries';
export type { E1rmPoint } from './queries';
// ─── Collections ─────────────────────────────────────────
export {

View file

@ -258,3 +258,54 @@ export function getActiveWorkout(workouts: BodyWorkout[]): BodyWorkout | null {
export function getActivePhase(phases: BodyPhase[]): BodyPhase | null {
return phases.find((p) => p.endDate === null) ?? null;
}
/**
* Most recent working (non-warmup) set for each exercise across the
* supplied set list. Used by the exercise picker to render the
* "Last: 80kg × 8 (3 days ago)" hint.
*/
export function getLastSetByExercise(sets: BodySet[]): Map<string, BodySet> {
const out = new Map<string, BodySet>();
for (const s of sets) {
if (s.isWarmup) continue;
const current = out.get(s.exerciseId);
if (!current || s.createdAt > current.createdAt) {
out.set(s.exerciseId, s);
}
}
return out;
}
/** Estimated 1RM (Epley) timeline for one exercise across all sets. */
export interface E1rmPoint {
date: string;
value: number;
}
export function getE1rmTimeline(sets: BodySet[], exerciseId: string): E1rmPoint[] {
// Best (highest) e1RM per calendar day — collapses multiple working sets
// in one session down to the most informative point so the chart isn't
// noisy with within-workout fluctuations.
const bestPerDay = new Map<string, number>();
for (const s of sets) {
if (s.exerciseId !== exerciseId || s.isWarmup) continue;
const day = (s.createdAt ?? '').split('T')[0];
if (!day) continue;
const e = estimateOneRepMax(s.weight, s.reps);
const prev = bestPerDay.get(day) ?? 0;
if (e > prev) bestPerDay.set(day, e);
}
return [...bestPerDay.entries()]
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([date, value]) => ({ date, value }));
}
/** Coarse "X days ago" formatter. */
export function relativeDays(iso: string, now = new Date()): string {
const then = new Date(iso);
const days = Math.floor((now.getTime() - then.getTime()) / (1000 * 60 * 60 * 24));
if (days <= 0) return 'heute';
if (days === 1) return 'gestern';
if (days < 7) return `vor ${days} Tagen`;
if (days < 30) return `vor ${Math.floor(days / 7)} Wochen`;
return `vor ${Math.floor(days / 30)} Monaten`;
}