feat(fitness): add guided body-parts measurement wizard

New /fitness/measure/body-parts route with step-by-step tape-measure
flow, per-step tips, L/R paired inputs, inline history chart, and
review-before-save summary. Measure page replaces the old body-parts
accordion with a launch button. Fitness layout goes full-bleed and
hides the site footer for this route via a :has() attribute selector,
and the desktop 3-column grid now extends rail and side panel up past
the floating nav while the middle column compensates with padding.
This commit is contained in:
2026-04-20 16:28:07 +02:00
parent b2b69301aa
commit 9c119b8df2
14 changed files with 1453 additions and 176 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "homepage",
"version": "1.35.3",
"version": "1.36.0",
"private": true,
"type": "module",
"scripts": {
+38
View File
@@ -214,6 +214,10 @@ const translations: Translations = {
r_thigh: { en: 'R Thigh', de: 'R Oberschenkel' },
l_calf: { en: 'L Calf', de: 'L Wade' },
r_calf: { en: 'R Calf', de: 'R Wade' },
biceps: { en: 'Biceps', de: 'Bizeps' },
forearms: { en: 'Forearms', de: 'Unterarme' },
thighs: { en: 'Thighs', de: 'Oberschenkel' },
calves: { en: 'Calves', de: 'Waden' },
measure_tip_neck: {
en: 'Just below the Adam\u2019s apple, tape parallel to the floor.',
de: 'Direkt unter dem Adamsapfel, Band parallel zum Boden.'
@@ -252,6 +256,40 @@ const translations: Translations = {
},
save_measurement: { en: 'Save Measurement', de: 'Messung speichern' },
update_measurement: { en: 'Update Measurement', de: 'Messung aktualisieren' },
measure_body_parts: { en: 'Measure body parts', de: 'Körpermasse erfassen' },
measure_body_parts_sub: {
en: 'Guided tape-measure flow \u2014 one part at a time.',
de: 'Gef\u00fchrter Ablauf \u2014 ein K\u00f6rperteil nach dem anderen.'
},
last_measured: { en: 'Last measured', de: 'Zuletzt gemessen' },
no_measurements_yet: { en: 'No measurements yet', de: 'Noch keine Messungen' },
step_n_of_m: { en: 'Step {n} of {m}', de: 'Schritt {n} von {m}' },
over_time: { en: '{label} over time', de: '{label} im Verlauf' },
first_measurement_hint: {
en: 'First measurement \u2014 your entry will appear here.',
de: 'Erste Messung \u2014 dein Wert erscheint hier.'
},
running_totals: { en: 'Running totals', de: 'Laufende \u00dcbersicht' },
review_save: { en: 'Review & save', de: 'Pr\u00fcfen & speichern' },
ready_to_save: { en: 'Ready to save', de: 'Bereit zum Speichern' },
review_numbers: { en: 'Review your numbers below.', de: 'Pr\u00fcfe deine Werte unten.' },
skip: { en: 'Skip', de: 'Auslassen' },
next: { en: 'Next', de: 'Weiter' },
back: { en: 'Back', de: 'Zur\u00fcck' },
review: { en: 'Review', de: 'Pr\u00fcfen' },
edit_again: { en: 'Edit again', de: 'Erneut bearbeiten' },
exit: { en: 'Exit', de: 'Schlie\u00dfen' },
same_both_sides: { en: 'Same on both sides', de: 'Auf beiden Seiten gleich' },
copy_l_to_r: { en: 'Copy L \u2192 R', de: 'L \u2192 R \u00fcbernehmen' },
kbd_nav: { en: 'nav', de: 'Navigation' },
kbd_next: { en: 'next', de: 'weiter' },
kbd_skip: { en: 'skip', de: 'auslassen' },
kbd_wheel: { en: '\u00b10.1', de: '\u00b10,1' },
no_body_parts_selected: {
en: 'Enter at least one value before saving.',
de: 'Bitte mindestens einen Wert eingeben.'
},
today_short: { en: 'today', de: 'heute' },
latest: { en: 'Latest', de: 'Aktuell' },
body_fat_short: { en: 'Body Fat', de: 'Körperfett' },
calories: { en: 'Calories', de: 'Kalorien' },
+4 -1
View File
@@ -68,7 +68,6 @@
!$page.url.pathname.startsWith(`/fitness/${s.nutrition}/food`) &&
!$page.url.pathname.startsWith(`/fitness/${s.nutrition}/meals`)
);
/** @param {number} secs */
function formatElapsed(secs) {
const m = Math.floor(secs / 60);
@@ -126,4 +125,8 @@
margin: 0 auto;
padding: var(--space-md, 1rem);
}
.fitness-content:has(> :global([data-fitness-fullbleed])) {
max-width: none;
padding: 0;
}
</style>
@@ -134,30 +134,18 @@
let formDate = $state(new Date().toISOString().slice(0, 10));
let formWeight = $state('');
let formBodyFat = $state('');
let formNeck = $state('');
let formShoulders = $state('');
let formChest = $state('');
let formBicepsL = $state('');
let formBicepsR = $state('');
let formForearmsL = $state('');
let formForearmsR = $state('');
let formWaist = $state('');
let formHips = $state('');
let formThighsL = $state('');
let formThighsR = $state('');
let formCalvesL = $state('');
let formCalvesR = $state('');
let showBodyFat = $state(false);
let showBodyParts = $state(false);
const formDirty = $derived(
!!formWeight || !!formBodyFat ||
!!formNeck || !!formShoulders || !!formChest ||
!!formBicepsL || !!formBicepsR || !!formForearmsL || !!formForearmsR ||
!!formWaist || !!formHips ||
!!formThighsL || !!formThighsR || !!formCalvesL || !!formCalvesR
);
const formDirty = $derived(!!formWeight || !!formBodyFat);
const hasAnyBodyPart = $derived(bodyPartFields.some((f) => f.value != null));
const latestBpDate = $derived(latest.measurements?.date ?? null);
function formatLatestBp() {
if (!latestBpDate) return t('no_measurements_yet', lang);
const d = new Date(latestBpDate);
return `${t('last_measured', lang)} · ${d.toLocaleDateString(lang === 'de' ? 'de-DE' : 'en-US', { month: 'short', day: 'numeric', year: 'numeric' })}`;
}
function stepWeight(delta) {
const cur = Number(formWeight) || lastWeight || 0;
@@ -171,36 +159,15 @@
else body.weight = null;
if (formBodyFat) body.bodyFatPercent = Number(formBodyFat);
else body.bodyFatPercent = null;
/** @type {any} */
const m = {};
if (formNeck) m.neck = Number(formNeck);
if (formShoulders) m.shoulders = Number(formShoulders);
if (formChest) m.chest = Number(formChest);
if (formBicepsL) m.leftBicep = Number(formBicepsL);
if (formBicepsR) m.rightBicep = Number(formBicepsR);
if (formForearmsL) m.leftForearm = Number(formForearmsL);
if (formForearmsR) m.rightForearm = Number(formForearmsR);
if (formWaist) m.waist = Number(formWaist);
if (formHips) m.hips = Number(formHips);
if (formThighsL) m.leftThigh = Number(formThighsL);
if (formThighsR) m.rightThigh = Number(formThighsR);
if (formCalvesL) m.leftCalf = Number(formCalvesL);
if (formCalvesR) m.rightCalf = Number(formCalvesR);
body.measurements = Object.keys(m).length > 0 ? m : null;
body.measurements = null;
return body;
}
function resetForm() {
formWeight = '';
formBodyFat = '';
formNeck = ''; formShoulders = ''; formChest = '';
formBicepsL = ''; formBicepsR = ''; formForearmsL = ''; formForearmsR = '';
formWaist = ''; formHips = '';
formThighsL = ''; formThighsR = ''; formCalvesL = ''; formCalvesR = '';
formDate = new Date().toISOString().slice(0, 10);
showBodyFat = false;
showBodyParts = false;
}
async function saveMeasurement() {
@@ -337,81 +304,14 @@
</div>
{/if}
<button type="button" class="section-toggle" onclick={() => showBodyParts = !showBodyParts}>
<span class="section-toggle-left">
<Ruler size={16} />
{t('body_parts_cm', lang)}
<a class="bp-launch" href="/fitness/{measureSlug}/body-parts">
<span class="bp-launch-icon"><Ruler size={22} /></span>
<span class="bp-launch-text">
<span class="bp-launch-title">{t('measure_body_parts', lang)}</span>
<span class="bp-launch-sub">{hasAnyBodyPart ? formatLatestBp() : t('measure_body_parts_sub', lang)}</span>
</span>
<ChevronDown size={16} class="chevron {showBodyParts ? 'open' : ''}" />
</button>
{#if showBodyParts}
<div class="section-body">
<div class="bp-group">
<span class="bp-group-label">{lang === 'en' ? 'Upper body' : 'Oberkörper'}</span>
<div class="bp-row">
<div class="bp-field">
<label for="m-neck">{t('neck', lang)}</label>
<input id="m-neck" type="number" step="0.1" bind:value={formNeck} placeholder="--" inputmode="decimal" />
<small class="bp-hint">{t('measure_tip_neck', lang)}</small>
</div>
<div class="bp-field">
<label for="m-shoulders">{t('shoulders', lang)}</label>
<input id="m-shoulders" type="number" step="0.1" bind:value={formShoulders} placeholder="--" inputmode="decimal" />
<small class="bp-hint">{t('measure_tip_shoulders', lang)}</small>
</div>
<div class="bp-field">
<label for="m-chest">{t('chest', lang)}</label>
<input id="m-chest" type="number" step="0.1" bind:value={formChest} placeholder="--" inputmode="decimal" />
<small class="bp-hint">{t('measure_tip_chest', lang)}</small>
</div>
</div>
</div>
<div class="bp-group">
<span class="bp-group-label">{lang === 'en' ? 'Arms' : 'Arme'}</span>
<div class="bp-row">
<div class="bp-field"><label for="m-bl">{t('l_bicep', lang)}</label><input id="m-bl" type="number" step="0.1" bind:value={formBicepsL} placeholder="--" inputmode="decimal" /></div>
<div class="bp-field"><label for="m-br">{t('r_bicep', lang)}</label><input id="m-br" type="number" step="0.1" bind:value={formBicepsR} placeholder="--" inputmode="decimal" /></div>
</div>
<p class="bp-row-hint">{t('measure_tip_biceps', lang)}</p>
<div class="bp-row">
<div class="bp-field"><label for="m-fl">{t('l_forearm', lang)}</label><input id="m-fl" type="number" step="0.1" bind:value={formForearmsL} placeholder="--" inputmode="decimal" /></div>
<div class="bp-field"><label for="m-fr">{t('r_forearm', lang)}</label><input id="m-fr" type="number" step="0.1" bind:value={formForearmsR} placeholder="--" inputmode="decimal" /></div>
</div>
<p class="bp-row-hint">{t('measure_tip_forearms', lang)}</p>
</div>
<div class="bp-group">
<span class="bp-group-label">{lang === 'en' ? 'Core' : 'Rumpf'}</span>
<div class="bp-row">
<div class="bp-field">
<label for="m-waist">{t('waist', lang)}</label>
<input id="m-waist" type="number" step="0.1" bind:value={formWaist} placeholder="--" inputmode="decimal" />
<small class="bp-hint">{t('measure_tip_waist', lang)}</small>
</div>
<div class="bp-field">
<label for="m-hips">{t('hips', lang)}</label>
<input id="m-hips" type="number" step="0.1" bind:value={formHips} placeholder="--" inputmode="decimal" />
<small class="bp-hint">{t('measure_tip_hips', lang)}</small>
</div>
</div>
</div>
<div class="bp-group">
<span class="bp-group-label">{lang === 'en' ? 'Legs' : 'Beine'}</span>
<div class="bp-row">
<div class="bp-field"><label for="m-tl">{t('l_thigh', lang)}</label><input id="m-tl" type="number" step="0.1" bind:value={formThighsL} placeholder="--" inputmode="decimal" /></div>
<div class="bp-field"><label for="m-tr">{t('r_thigh', lang)}</label><input id="m-tr" type="number" step="0.1" bind:value={formThighsR} placeholder="--" inputmode="decimal" /></div>
</div>
<p class="bp-row-hint">{t('measure_tip_thighs', lang)}</p>
<div class="bp-row">
<div class="bp-field"><label for="m-cl">{t('l_calf', lang)}</label><input id="m-cl" type="number" step="0.1" bind:value={formCalvesL} placeholder="--" inputmode="decimal" /></div>
<div class="bp-field"><label for="m-cr">{t('r_calf', lang)}</label><input id="m-cr" type="number" step="0.1" bind:value={formCalvesR} placeholder="--" inputmode="decimal" /></div>
</div>
<p class="bp-row-hint">{t('measure_tip_calves', lang)}</p>
</div>
</div>
{/if}
<ChevronRight size={18} />
</a>
{#if formDirty && !workout.active}
<SaveFab disabled={saving} label={t('save_measurement', lang)} />
@@ -800,70 +700,68 @@
color: var(--color-text-secondary);
}
.bp-group {
margin-bottom: 0.6rem;
}
.bp-group:last-child {
margin-bottom: 0;
}
.bp-group-label {
display: block;
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-tertiary);
margin-bottom: 0.35rem;
}
.bp-row {
.bp-launch {
display: flex;
gap: 0.5rem;
margin-bottom: 0.35rem;
align-items: center;
gap: 0.85rem;
width: 100%;
padding: 0.9rem 1rem;
margin-top: 0.5rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
color: var(--color-text-primary);
text-decoration: none;
cursor: pointer;
transition: all var(--transition-normal);
position: relative;
overflow: hidden;
}
.bp-field {
flex: 1;
.bp-launch::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
135deg,
color-mix(in oklab, var(--color-primary) 8%, transparent),
transparent 60%
);
pointer-events: none;
}
.bp-launch:hover {
border-color: var(--color-primary);
box-shadow: var(--shadow-md);
transform: translateY(-1px);
}
.bp-launch-icon {
display: grid;
place-items: center;
width: 2.6rem;
height: 2.6rem;
flex-shrink: 0;
border-radius: 50%;
background: color-mix(in oklab, var(--color-primary) 14%, transparent);
color: var(--color-primary);
}
.bp-launch-text {
display: flex;
flex-direction: column;
gap: 0.15rem;
flex: 1;
min-width: 0;
}
.bp-field label {
font-size: 0.65rem;
font-weight: 600;
.bp-launch-title {
font-size: 0.95rem;
font-weight: 700;
letter-spacing: -0.005em;
}
.bp-launch-sub {
font-size: 0.72rem;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.bp-field input {
padding: 0.4rem 0.5rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg-tertiary);
color: inherit;
font-size: 0.85rem;
width: 100%;
box-sizing: border-box;
}
.bp-field input:focus {
outline: none;
border-color: var(--color-primary);
}
.bp-hint {
display: block;
margin-top: 0.3rem;
font-size: 0.66rem;
font-style: italic;
line-height: 1.35;
color: var(--color-text-tertiary);
letter-spacing: 0.01em;
}
.bp-row-hint {
margin: 0.1rem 0 0.55rem;
padding: 0.2rem 0 0.2rem 0.55rem;
border-left: 2px solid var(--color-primary);
font-size: 0.7rem;
font-style: italic;
line-height: 1.4;
color: var(--color-text-tertiary);
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Body parts (latest) */
@@ -0,0 +1,9 @@
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ fetch }) => {
const res = await fetch('/api/fitness/measurements?limit=100');
const list = res.ok ? await res.json() : { measurements: [] };
return {
measurements: list.measurements ?? []
};
};
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB