2 Commits

Author SHA1 Message Date
Alexander 0a188ad4ab fix(fitness): measure page polish — stable steppers, narrow history, body-parts chrome tweaks
CI / update (push) Successful in 4m5s
- Lock +/- button positions by normalizing stepped weight/body-fat
  values to .toFixed(1) so trailing zeros stay; placeholders also
  normalized. Input width no longer jitters through a step sequence.
- Cap .history-section width on mobile/tablet to match .main-col
  (480px / 760px) so "Past measurements" aligns with the metric cards.
- Body-parts page:
  - Remove the "Running totals" list from the right panel.
  - Hide the keyboard-shortcut legend by default; show on `?` (toggle)
    or Escape (dismiss), with a small `?` pill hint in its place.
    Added kbd_hint i18n string.
  - Push skip + back/next toward the edges of the bottombar; pull
    progress dots + close button inward symmetrically.
  - Center the keyboard legend / hint on the screen width rather than
    between the skip and nav buttons (position: absolute + translate).
2026-04-23 11:31:25 +02:00
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
5 changed files with 713 additions and 295 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "homepage", "name": "homepage",
"version": "1.45.1", "version": "1.46.1",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+2
View File
@@ -297,6 +297,7 @@ const translations: Translations = {
kbd_next: { en: 'next', de: 'weiter' }, kbd_next: { en: 'next', de: 'weiter' },
kbd_skip: { en: 'skip', de: 'auslassen' }, kbd_skip: { en: 'skip', de: 'auslassen' },
kbd_wheel: { en: '\u00b10.1', de: '\u00b10,1' }, kbd_wheel: { en: '\u00b10.1', de: '\u00b10,1' },
kbd_hint: { en: 'Press ? for shortcuts', de: '? dr\u00fccken f\u00fcr Tastenk\u00fcrzel' },
no_body_parts_selected: { no_body_parts_selected: {
en: 'Enter at least one value before saving.', en: 'Enter at least one value before saving.',
de: 'Bitte mindestens einen Wert eingeben.' de: 'Bitte mindestens einen Wert eingeben.'
@@ -311,6 +312,7 @@ const translations: Translations = {
general: { en: 'General', de: 'Allgemein' }, general: { en: 'General', de: 'Allgemein' },
body_fat_pct: { en: 'Body Fat (%)', de: 'Körperfett (%)' }, body_fat_pct: { en: 'Body Fat (%)', de: 'Körperfett (%)' },
history: { en: 'History', de: 'Verlauf' }, history: { en: 'History', de: 'Verlauf' },
past_measurements: { en: 'Past measurements', de: 'Frühere Messungen' },
// SetTable // SetTable
set_header: { en: 'SET', de: 'SATZ' }, set_header: { en: 'SET', de: 'SATZ' },
+4 -1
View File
@@ -68,6 +68,9 @@
!$page.url.pathname.startsWith(`/fitness/${s.nutrition}/food`) && !$page.url.pathname.startsWith(`/fitness/${s.nutrition}/food`) &&
!$page.url.pathname.startsWith(`/fitness/${s.nutrition}/meals`) !$page.url.pathname.startsWith(`/fitness/${s.nutrition}/meals`)
); );
const isMeasureIndex = $derived(
/^\/fitness\/(measure|messen)\/?$/.test($page.url.pathname)
);
/** @param {number} secs */ /** @param {number} secs */
function formatElapsed(secs) { function formatElapsed(secs) {
const m = Math.floor(secs / 60); const m = Math.floor(secs / 60);
@@ -100,7 +103,7 @@
<UserHeader {user} /> <UserHeader {user} />
{/snippet} {/snippet}
<div class="fitness-content" style:--fitness-max-width={isNutritionPage ? '1400px' : null}> <div class="fitness-content" style:--fitness-max-width={isNutritionPage || isMeasureIndex ? '1400px' : null}>
{@render children()} {@render children()}
</div> </div>
</Header> </Header>
File diff suppressed because it is too large Load Diff
@@ -8,6 +8,7 @@
import { toast } from '$lib/js/toast.svelte'; import { toast } from '$lib/js/toast.svelte';
import DatePicker from '$lib/components/DatePicker.svelte'; import DatePicker from '$lib/components/DatePicker.svelte';
import SaveFab from '$lib/components/SaveFab.svelte'; import SaveFab from '$lib/components/SaveFab.svelte';
import Toggle from '$lib/components/Toggle.svelte';
import { bodyPartAccent } from '$lib/js/fitnessBodyParts'; import { bodyPartAccent } from '$lib/js/fitnessBodyParts';
let { data } = $props(); let { data } = $props();
@@ -139,14 +140,18 @@
return v ? `${v} cm` : '—'; return v ? `${v} cm` : '—';
} }
let showShortcuts = $state(false);
/** @param {KeyboardEvent} e */ /** @param {KeyboardEvent} e */
function onkey(e) { function onkey(e) {
const tag = /** @type {HTMLElement|null} */ (e.target)?.tagName;
const inInput = tag === 'INPUT';
if (e.key === '?' && !inInput) { e.preventDefault(); showShortcuts = !showShortcuts; return; }
if (e.key === 'Escape' && showShortcuts) { e.preventDefault(); showShortcuts = false; return; }
if (done) { if (done) {
if (e.key === 'ArrowLeft') { e.preventDefault(); idx = total - 1; direction = -1; } if (e.key === 'ArrowLeft') { e.preventDefault(); idx = total - 1; direction = -1; }
return; return;
} }
const tag = /** @type {HTMLElement|null} */ (e.target)?.tagName;
const inInput = tag === 'INPUT';
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); next(); } if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); next(); }
else if (e.key === 'ArrowRight' && !inInput) { e.preventDefault(); next(); } else if (e.key === 'ArrowRight' && !inInput) { e.preventDefault(); next(); }
else if (e.key === 'ArrowLeft' && !inInput) { e.preventDefault(); back(); } else if (e.key === 'ArrowLeft' && !inInput) { e.preventDefault(); back(); }
@@ -427,10 +432,9 @@
</button> </button>
</div> </div>
{/if} {/if}
<label class="same-toggle"> <div class="same-toggle">
<input type="checkbox" bind:checked={pv.same} /> <Toggle bind:checked={pv.same} label={t('same_both_sides', lang)} />
<span>{t('same_both_sides', lang)}</span> </div>
</label>
{:else} {:else}
<div class="stepper" onwheel={(e) => onWheel(e, step.key, null)}> <div class="stepper" onwheel={(e) => onWheel(e, step.key, null)}>
<button type="button" class="step-btn" onclick={() => bump(step.key, null, -0.5)} aria-label="-0.5"> <button type="button" class="step-btn" onclick={() => bump(step.key, null, -0.5)} aria-label="-0.5">
@@ -477,7 +481,7 @@
{/if} {/if}
</main> </main>
<aside class="panel" aria-label={t('running_totals', lang)}> <aside class="panel">
{#if !done} {#if !done}
{#key step.key} {#key step.key}
<div class="panel-section chart-section" in:fade={{ duration: 180 }}> <div class="panel-section chart-section" in:fade={{ duration: 180 }}>
@@ -549,32 +553,29 @@
{/key} {/key}
{/if} {/if}
<div class="panel-section totals-section">
<div class="panel-head">
<h3 class="panel-title">{t('running_totals', lang)}</h3>
</div>
<ul class="totals">
{#each steps as s, i (s.key)}
<li class:dim={!isFilled(s)} class:focused={i === idx && !done}>
<button type="button" class="totals-item" onclick={() => jumpTo(i)}>
<span class="totals-label">{stepLabel(s)}</span>
<span class="totals-val">{formatValue(s)}</span>
</button>
</li>
{/each}
</ul>
</div>
</aside> </aside>
<footer class="bottombar"> <footer class="bottombar">
{#if !done} {#if !done}
<button type="button" class="ghost" onclick={skip}>{t('skip', lang)}</button> <button type="button" class="ghost" onclick={skip}>{t('skip', lang)}</button>
<div class="kbd-legend" aria-hidden="true"> {#if showShortcuts}
<span><kbd></kbd><kbd></kbd> {t('kbd_nav', lang)}</span> <div class="kbd-legend" aria-hidden="true">
<span><kbd></kbd> {t('kbd_next', lang)}</span> <span><kbd></kbd><kbd></kbd> {t('kbd_nav', lang)}</span>
<span><kbd>S</kbd> {t('kbd_skip', lang)}</span> <span><kbd></kbd> {t('kbd_next', lang)}</span>
<span><kbd>scroll</kbd> {t('kbd_wheel', lang)}</span> <span><kbd>S</kbd> {t('kbd_skip', lang)}</span>
</div> <span><kbd>scroll</kbd> {t('kbd_wheel', lang)}</span>
</div>
{:else}
<button
type="button"
class="kbd-hint"
onclick={() => showShortcuts = true}
aria-label={t('kbd_hint', lang)}
title={t('kbd_hint', lang)}
>
<kbd>?</kbd>
</button>
{/if}
<div class="nav-pair"> <div class="nav-pair">
<button type="button" class="nav-btn" onclick={back} disabled={idx === 0} aria-label={t('back', lang)}> <button type="button" class="nav-btn" onclick={back} disabled={idx === 0} aria-label={t('back', lang)}>
<ArrowLeft size={16} /> <ArrowLeft size={16} />
@@ -617,7 +618,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 1rem 1.25rem; padding: 1rem 2rem;
} }
.progress { .progress {
display: flex; display: flex;
@@ -859,30 +860,18 @@
.same-toggle { .same-toggle {
display: inline-flex; display: inline-flex;
align-items: center;
gap: 0.45rem;
font-size: 0.78rem;
color: var(--color-text-secondary);
cursor: pointer;
user-select: none;
}
.same-toggle input {
accent-color: var(--color-primary);
width: 0.95rem;
height: 0.95rem;
} }
.panel { display: none; } .panel { display: none; }
.bottombar { .bottombar {
position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 1rem 1.25rem 1.5rem; padding: 1rem 0.5rem 1.5rem;
gap: 0.75rem; gap: 0.75rem;
max-width: 520px;
width: 100%; width: 100%;
margin-inline: auto;
} }
.ghost { .ghost {
border: none; border: none;
@@ -924,6 +913,7 @@
} }
.kbd-legend { display: none; } .kbd-legend { display: none; }
.kbd-hint { display: none; }
.bottom-spacer { flex: 1; } .bottom-spacer { flex: 1; }
.summary { .summary {
@@ -1005,7 +995,7 @@
.topbar { grid-area: topbar; padding: calc(1.25rem + var(--fitness-header-offset)) 2rem 0; } .topbar { grid-area: topbar; padding: calc(1.25rem + var(--fitness-header-offset)) 2rem 0; }
.stage { grid-area: stage; padding: 1.5rem 2rem 2rem; align-items: center; } .stage { grid-area: stage; padding: 1.5rem 2rem 2rem; align-items: center; }
.panel { grid-area: panel; } .panel { grid-area: panel; }
.bottombar { grid-area: bottom; max-width: none; margin-inline: 0; padding: 1rem 2rem 1.5rem; } .bottombar { grid-area: bottom; max-width: none; margin-inline: 0; padding: 1rem 0.5rem 1.5rem; }
.topbar .progress { visibility: hidden; } .topbar .progress { visibility: hidden; }
@@ -1290,20 +1280,25 @@
.stepper.compact { flex: 1 1 180px; min-width: 180px; } .stepper.compact { flex: 1 1 180px; min-width: 180px; }
.kbd-legend { .kbd-legend {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
display: flex; display: flex;
gap: 1rem; gap: 1rem;
font-size: 0.68rem; font-size: 0.68rem;
color: var(--color-text-tertiary); color: var(--color-text-tertiary);
flex: 1;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
pointer-events: none;
} }
.kbd-legend span { .kbd-legend span {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.3rem; gap: 0.3rem;
} }
.kbd-legend kbd { .kbd-legend kbd,
.kbd-hint kbd {
font-family: inherit; font-family: inherit;
display: inline-grid; display: inline-grid;
place-items: center; place-items: center;
@@ -1318,6 +1313,23 @@
font-size: 0.65rem; font-size: 0.65rem;
font-weight: 700; font-weight: 700;
} }
.kbd-hint {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
display: inline-flex;
align-items: center;
justify-content: center;
background: none;
border: none;
padding: 0.25rem 0.5rem;
cursor: pointer;
color: var(--color-text-tertiary);
opacity: 0.6;
transition: opacity 150ms;
}
.kbd-hint:hover { opacity: 1; }
} }
@media (min-width: 1400px) { @media (min-width: 1400px) {