mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:01:08 +02:00
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:
parent
77ad48972e
commit
b2f3b313bb
11 changed files with 1276 additions and 38 deletions
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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[],
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -30,7 +30,11 @@ export {
|
|||
getActiveExercises,
|
||||
getActiveWorkout,
|
||||
getActivePhase,
|
||||
getLastSetByExercise,
|
||||
getE1rmTimeline,
|
||||
relativeDays,
|
||||
} from './queries';
|
||||
export type { E1rmPoint } from './queries';
|
||||
|
||||
// ─── Collections ─────────────────────────────────────────
|
||||
export {
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue