Files
homepage/src/routes/fitness/+layout.svelte
T
Alexander def176db4d feat(fitness): redesign measure page with muscle-man map, inline edit, and desktop 2-col layout
- Weight + body fat cards share a unified .metric-card component with wheel
  + keyboard (Arrow/Shift+Arrow) stepping. Side-by-side on tablet and up.
- Replaced body-parts accordion with a prominent card showing a cropped
  muscle-front silhouette and overlay dots/bands marking which regions
  have measurements. Shoulders + chest render as dotted tape-measure
  bands; other parts as dots. "Last measured" now relative (N days ago).
- Desktop layout: .main-col (form + period tracker) left, history on
  right. Two columns center together at wider widths instead of drifting
  apart. Fitness layout detects measure index and bumps max-width to
  1400px, matching nutrition.
- Inline history edit: pencil swaps the row for a compact date/kg/%
  form (Enter saves, Escape cancels) via PUT /api/fitness/measurements.
  Full-edit link preserved for body-parts tweaks.
- Body-parts history heading renamed to "Past measurements" /
  "Frühere Messungen" to avoid collision with the period tracker's
  own history.
- "Profil bearbeiten" moved to the top-left of the main column.
- Same-sides toggle in the body-parts flow now uses the shared Toggle
  component.
2026-04-23 11:08:41 +02:00

136 lines
5.4 KiB
Svelte

<script>
import { page } from '$app/stores';
import { onMount, onDestroy } from 'svelte';
import Header from '$lib/components/Header.svelte';
import UserHeader from '$lib/components/UserHeader.svelte';
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
import { BarChart3, Clock, Dumbbell, ListChecks, Ruler, UtensilsCrossed } from '@lucide/svelte';
import { getWorkout } from '$lib/js/workout.svelte';
import { getWorkoutSync } from '$lib/js/workoutSync.svelte';
import WorkoutFab from '$lib/components/fitness/WorkoutFab.svelte';
import { detectFitnessLang, fitnessSlugs, fitnessLabels } from '$lib/js/fitnessI18n';
import { flushQueue } from '$lib/offline/fitnessQueue';
let { data, children } = $props();
let user = $derived(data.session?.user);
const workout = getWorkout();
const sync = getWorkoutSync();
const lang = $derived(detectFitnessLang($page.url.pathname));
const s = $derived(fitnessSlugs(lang));
const labels = $derived(fitnessLabels(lang));
/** Pre-cache all fitness page shells so they work offline */
function precacheFitnessShells() {
if (!('serviceWorker' in navigator) || !navigator.serviceWorker.controller) return;
const slugs = [
'workout', 'training', 'workout/active', 'training/aktiv',
'exercises', 'uebungen', 'stats', 'statistik',
'history', 'verlauf', 'measure', 'messen',
'nutrition', 'ernaehrung'
];
const urls = slugs.map((s) => `/fitness/${s}`);
navigator.serviceWorker.controller.postMessage({ type: 'CACHE_PAGES', urls });
// Also cache __data.json for client-side navigation
const dataUrls = slugs.map((s) => `/fitness/${s}/__data.json`);
navigator.serviceWorker.controller.postMessage({ type: 'CACHE_DATA', urls: dataUrls });
}
function onOnline() {
flushQueue();
}
onMount(async () => {
workout.restore();
workout.onChange(() => sync.notifyChange());
await sync.init();
flushQueue();
precacheFitnessShells();
window.addEventListener('online', onOnline);
});
onDestroy(() => {
sync.destroy();
if (typeof window !== 'undefined') window.removeEventListener('online', onOnline);
});
/** @param {string} path */
function isActive(path) {
const currentPath = $page.url.pathname;
return currentPath.startsWith(path);
}
const activePath = $derived(`/fitness/${s.workout}/${s.active}`);
const isOnActivePage = $derived($page.url.pathname === activePath);
const isNutritionPage = $derived(
$page.url.pathname.startsWith(`/fitness/${s.nutrition}`) &&
!$page.url.pathname.startsWith(`/fitness/${s.nutrition}/food`) &&
!$page.url.pathname.startsWith(`/fitness/${s.nutrition}/meals`)
);
const isMeasureIndex = $derived(
/^\/fitness\/(measure|messen)\/?$/.test($page.url.pathname)
);
/** @param {number} secs */
function formatElapsed(secs) {
const m = Math.floor(secs / 60);
const sec = secs % 60;
return `${m}:${sec.toString().padStart(2, '0')}`;
}
</script>
<Header>
{#snippet links()}
<ul class="site_header">
<li><a href="/fitness/{s.stats}" class:active={isActive(`/fitness/${s.stats}`)}><BarChart3 size={16} strokeWidth={1.5} class="nav-icon" /><span class="nav-label">{labels.stats}</span></a></li>
<li style="--active-fill: var(--nord13)"><a href="/fitness/{s.history}" class:active={isActive(`/fitness/${s.history}`)}><Clock size={16} strokeWidth={1.5} class="nav-icon" /><span class="nav-label">{labels.history}</span></a></li>
<li style="--active-fill: var(--nord8)"><a href="/fitness/{s.workout}" class:active={isActive(`/fitness/${s.workout}`)}><Dumbbell size={16} strokeWidth={1.5} class="nav-icon" /><span class="nav-label">{labels.workout}</span></a></li>
<li style="--active-fill: var(--nord14)"><a href="/fitness/{s.exercises}" class:active={isActive(`/fitness/${s.exercises}`)}><ListChecks size={16} strokeWidth={1.5} class="nav-icon" /><span class="nav-label">{labels.exercises}</span></a></li>
<li style="--active-fill: var(--nord12)"><a href="/fitness/{s.measure}" class:active={isActive(`/fitness/${s.measure}`)}><Ruler size={16} strokeWidth={1.5} class="nav-icon" /><span class="nav-label">{labels.measure}</span></a></li>
<li style="--active-fill: var(--nord15)"><a href="/fitness/{s.nutrition}" class:active={isActive(`/fitness/${s.nutrition}`)}><UtensilsCrossed size={16} strokeWidth={1.5} class="nav-icon" /><span class="nav-label">{labels.nutrition}</span></a></li>
</ul>
{/snippet}
{#snippet language_selector_mobile()}
<LanguageSelector lang={lang} />
{/snippet}
{#snippet language_selector_desktop()}
<LanguageSelector lang={lang} />
{/snippet}
{#snippet right_side()}
<UserHeader {user} />
{/snippet}
<div class="fitness-content" style:--fitness-max-width={isNutritionPage || isMeasureIndex ? '1400px' : null}>
{@render children()}
</div>
</Header>
{#if workout.active && !isOnActivePage}
<WorkoutFab
href={activePath}
elapsed={formatElapsed(workout.elapsedSeconds)}
paused={workout.paused}
syncStatus={sync.status}
onPauseToggle={() => workout.paused ? workout.resumeTimer() : workout.pauseTimer()}
restSeconds={workout.restTimerSeconds}
restTotal={workout.restTimerTotal}
onRestAdjust={(delta) => workout.adjustRestTimer(delta)}
onRestSkip={() => workout.cancelRestTimer()}
/>
{/if}
<style>
.fitness-content {
max-width: var(--fitness-max-width, 900px);
margin: 0 auto;
padding: var(--space-md, 1rem);
}
.fitness-content:has(> :global([data-fitness-fullbleed])) {
max-width: none;
padding: 0;
}
</style>