feat(fitness): body-part cards link to per-part history pages

Latest measurements render as a 3x3 card grid (vertical mobile,
horizontal on ≥768px) linking to `/fitness/{measure}/{history}/{part}`
with summary stats + chart. Slugs localize (hals, oberschenkel, …)
when on the German route.
This commit is contained in:
2026-04-21 08:28:45 +02:00
parent 2b1a415ab6
commit c45585baa5
6 changed files with 643 additions and 18 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "homepage", "name": "homepage",
"version": "1.37.7", "version": "1.38.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+38
View File
@@ -0,0 +1,38 @@
export type SingleBodyPartCard = {
key: string;
slugDe: string;
labelKey: string;
img: string | null;
paired: false;
db: string;
};
export type PairedBodyPartCard = {
key: string;
slugDe: string;
labelKey: string;
img: string | null;
paired: true;
dbLeft: string;
dbRight: string;
};
export type BodyPartCard = SingleBodyPartCard | PairedBodyPartCard;
export const BODY_PART_CARDS: BodyPartCard[] = [
{ key: 'neck', slugDe: 'hals', labelKey: 'neck', img: 'neck.png', paired: false, db: 'neck' },
{ key: 'shoulders', slugDe: 'schultern', labelKey: 'shoulders', img: 'back.png', paired: false, db: 'shoulders' },
{ key: 'chest', slugDe: 'brust', labelKey: 'chest', img: 'shoulders.png', paired: false, db: 'chest' },
{ key: 'biceps', slugDe: 'bizeps', labelKey: 'biceps', img: 'bicep.png', paired: true, dbLeft: 'leftBicep', dbRight: 'rightBicep' },
{ key: 'forearms', slugDe: 'unterarme', labelKey: 'forearms', img: null, paired: true, dbLeft: 'leftForearm', dbRight: 'rightForearm' },
{ key: 'waist', slugDe: 'taille', labelKey: 'waist', img: 'waist.png', paired: false, db: 'waist' },
{ key: 'hips', slugDe: 'huefte', labelKey: 'hips', img: 'hips.png', paired: false, db: 'hips' },
{ key: 'thighs', slugDe: 'oberschenkel', labelKey: 'thighs', img: 'thigh.svg', paired: true, dbLeft: 'leftThigh', dbRight: 'rightThigh' },
{ key: 'calves', slugDe: 'waden', labelKey: 'calves', img: 'calves.png', paired: true, dbLeft: 'leftCalf', dbRight: 'rightCalf' }
];
export function findBodyPart(slug: string): BodyPartCard | null {
return BODY_PART_CARDS.find((c) => c.key === slug || c.slugDe === slug) ?? null;
}
export function bodyPartSlug(card: BodyPartCard, lang: string): string {
return lang === 'de' ? card.slugDe : card.key;
}
@@ -3,7 +3,7 @@ import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ fetch }) => { export const load: PageServerLoad = async ({ fetch }) => {
const [latestRes, listRes, goalRes, periodRes, shareRes, sharedRes] = await Promise.all([ const [latestRes, listRes, goalRes, periodRes, shareRes, sharedRes] = await Promise.all([
fetch('/api/fitness/measurements/latest'), fetch('/api/fitness/measurements/latest'),
fetch('/api/fitness/measurements?limit=20'), fetch('/api/fitness/measurements?limit=200'),
fetch('/api/fitness/goal'), fetch('/api/fitness/goal'),
fetch('/api/fitness/period').catch(() => null), fetch('/api/fitness/period').catch(() => null),
fetch('/api/fitness/period/share').catch(() => null), fetch('/api/fitness/period/share').catch(() => null),
@@ -6,9 +6,11 @@
import { confirm } from '$lib/js/confirmDialog.svelte'; import { confirm } from '$lib/js/confirmDialog.svelte';
import SaveFab from '$lib/components/SaveFab.svelte'; import SaveFab from '$lib/components/SaveFab.svelte';
import DatePicker from '$lib/components/DatePicker.svelte'; import DatePicker from '$lib/components/DatePicker.svelte';
import { BODY_PART_CARDS, bodyPartSlug } from '$lib/js/fitnessBodyParts';
const lang = $derived(detectFitnessLang($page.url.pathname)); const lang = $derived(detectFitnessLang($page.url.pathname));
const measureSlug = $derived(lang === 'en' ? 'measure' : 'messen'); const measureSlug = $derived(lang === 'en' ? 'measure' : 'messen');
const historySlug = $derived(lang === 'en' ? 'history' : 'verlauf');
import { getWorkout } from '$lib/js/workout.svelte'; import { getWorkout } from '$lib/js/workout.svelte';
import PeriodTracker from '$lib/components/fitness/PeriodTracker.svelte'; import PeriodTracker from '$lib/components/fitness/PeriodTracker.svelte';
@@ -94,6 +96,26 @@
{ label: t('r_calf', lang), key: 'rightCalf', value: latestBp.rightCalf } { label: t('r_calf', lang), key: 'rightCalf', value: latestBp.rightCalf }
]); ]);
/** @param {import('$lib/js/fitnessBodyParts').BodyPartCard} c */
function currentValue(c) {
if (c.paired) {
const l = /** @type {number|undefined} */ (latestBp[c.dbLeft]);
const r = /** @type {number|undefined} */ (latestBp[c.dbRight]);
return { left: l ?? null, right: r ?? null };
}
const v = /** @type {number|undefined} */ (latestBp[c.db]);
return { value: v ?? null };
}
/** @param {import('$lib/js/fitnessBodyParts').BodyPartCard} c */
function hasAny(c) {
const v = currentValue(c);
if (c.paired) return v.left != null || v.right != null;
return v.value != null;
}
const cardsWithData = $derived(BODY_PART_CARDS.filter(hasAny));
/** @param {string} id */ /** @param {string} id */
async function deleteMeasurement(id) { async function deleteMeasurement(id) {
if (!await confirm(t('delete_measurement_confirm', lang))) return; if (!await confirm(t('delete_measurement_confirm', lang))) return;
@@ -318,15 +340,50 @@
{/if} {/if}
</form> </form>
{#if bodyPartFields.some(f => f.value != null)} {#if cardsWithData.length > 0}
<section class="body-parts-section"> <section class="body-parts-section">
<h2>{t('body_parts', lang)}</h2> <h2>{t('body_parts', lang)}</h2>
<div class="body-grid"> <div class="bp-grid">
{#each bodyPartFields.filter(f => f.value != null) as field} {#each cardsWithData as card (card.key)}
<div class="body-row"> {@const cv = currentValue(card)}
<span class="body-label">{field.label}</span> <a
<span class="body-value">{field.value} cm</span> class="bp-card"
</div> href="/fitness/{measureSlug}/{historySlug}/{bodyPartSlug(card, lang)}"
>
<div class="bp-img-wrap" aria-hidden="true">
{#if card.img && card.img.endsWith('.svg')}
<div
class="bp-img bp-img-svg"
style="--bp-svg-src: url(/fitness/measure/{card.img})"
></div>
{:else if card.img}
<img src="/fitness/measure/{card.img}" alt="" class="bp-img" />
{:else}
<Ruler size={36} strokeWidth={1.5} />
{/if}
</div>
<div class="bp-meta">
<span class="bp-label">{t(card.labelKey, lang)}</span>
{#if card.paired}
{#if cv.left != null && cv.right != null && cv.left === cv.right}
<span class="bp-value">{cv.left.toFixed(1)}<span class="bp-unit">cm</span></span>
{:else if cv.left != null && cv.right != null}
<span class="bp-value paired">
<span class="bp-side"><em>L</em> {cv.left.toFixed(1)}</span>
<span class="bp-side-sep">·</span>
<span class="bp-side"><em>R</em> {cv.right.toFixed(1)}</span>
<span class="bp-unit">cm</span>
</span>
{:else if cv.left != null}
<span class="bp-value"><em>L</em> {cv.left.toFixed(1)}<span class="bp-unit">cm</span></span>
{:else if cv.right != null}
<span class="bp-value"><em>R</em> {cv.right.toFixed(1)}<span class="bp-unit">cm</span></span>
{/if}
{:else if cv.value != null}
<span class="bp-value">{cv.value.toFixed(1)}<span class="bp-unit">cm</span></span>
{/if}
</div>
</a>
{/each} {/each}
</div> </div>
</section> </section>
@@ -765,22 +822,153 @@
} }
/* Body parts (latest) */ /* Body parts (latest) */
.body-grid { .bp-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.6rem;
}
.bp-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center;
text-align: center;
gap: 0.35rem;
padding: 0.7rem 0.5rem 0.6rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
cursor: pointer;
color: inherit;
font: inherit;
text-decoration: none;
position: relative;
transition: border-color var(--transition-normal), box-shadow var(--transition-normal), transform var(--transition-normal);
} }
.body-row { .bp-card:hover {
display: flex; border-color: var(--color-primary);
justify-content: space-between; box-shadow: var(--shadow-sm);
padding: 0.5rem 0;
border-bottom: 1px solid var(--color-border);
font-size: 0.85rem;
} }
.body-label { .bp-img-wrap {
display: grid;
place-items: center;
width: 3.25rem;
height: 3.25rem;
flex-shrink: 0;
border-radius: 50%;
background: var(--color-bg-secondary);
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }
.body-value { .bp-img {
width: 2.4rem;
height: 2.4rem;
object-fit: contain;
}
.bp-img-svg {
mask-image: var(--bp-svg-src);
-webkit-mask-image: var(--bp-svg-src);
mask-size: contain;
-webkit-mask-size: contain;
mask-repeat: no-repeat;
-webkit-mask-repeat: no-repeat;
mask-position: center;
-webkit-mask-position: center;
background-color: var(--color-text-primary);
}
@media (prefers-color-scheme: dark) {
img.bp-img { filter: invert(1); }
}
:global(:root[data-theme="dark"]) img.bp-img { filter: invert(1); }
:global(:root[data-theme="light"]) img.bp-img { filter: none; }
.bp-meta {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.1rem;
min-width: 0;
width: 100%;
}
.bp-label {
font-size: 0.65rem;
font-weight: 600; font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-secondary);
}
.bp-value {
font-size: 1rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--color-text-primary);
letter-spacing: -0.01em;
}
.bp-value.paired {
font-size: 0.78rem;
display: inline-flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: center;
gap: 0.2rem;
}
.bp-value em {
font-style: normal;
font-weight: 600;
font-size: 0.62rem;
color: var(--color-text-tertiary);
margin-right: 0.15rem;
letter-spacing: 0.05em;
}
.bp-side {
white-space: nowrap;
}
.bp-side-sep {
color: var(--color-text-tertiary);
}
.bp-unit {
margin-left: 0.2rem;
font-size: 0.65rem;
font-weight: 600;
color: var(--color-text-tertiary);
}
@media (max-width: 420px) {
.bp-grid { gap: 0.45rem; }
.bp-card { padding: 0.55rem 0.35rem; }
.bp-img-wrap { width: 2.6rem; height: 2.6rem; }
.bp-img { width: 1.9rem; height: 1.9rem; }
.bp-label { font-size: 0.58rem; }
.bp-value { font-size: 0.88rem; }
.bp-value.paired { font-size: 0.7rem; }
}
@media (min-width: 768px) {
.bp-grid { gap: 0.85rem; }
.bp-card {
flex-direction: row;
align-items: center;
text-align: left;
gap: 0.85rem;
padding: 0.9rem 1rem;
}
.bp-img-wrap {
width: 3.75rem;
height: 3.75rem;
}
.bp-img {
width: 2.75rem;
height: 2.75rem;
}
.bp-meta {
align-items: flex-start;
text-align: left;
gap: 0.25rem;
}
.bp-value.paired {
justify-content: flex-start;
}
.bp-label {
font-size: 0.68rem;
}
.bp-value {
font-size: 1.15rem;
}
} }
/* History */ /* History */
@@ -0,0 +1,16 @@
import { error } from '@sveltejs/kit';
import { findBodyPart } from '$lib/js/fitnessBodyParts';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ fetch, params }) => {
const card = findBodyPart(params.part);
if (!card) throw error(404, 'Unknown body part');
const res = await fetch('/api/fitness/measurements?limit=500');
const list = res.ok ? await res.json() : { measurements: [] };
return {
card,
measurements: list.measurements ?? []
};
};
@@ -0,0 +1,383 @@
<script>
import { page } from '$app/stores';
import { ArrowLeft, Ruler, TrendingUp, TrendingDown, Minus as MinusIcon } from '@lucide/svelte';
import { detectFitnessLang, t } from '$lib/js/fitnessI18n';
import FitnessChart from '$lib/components/fitness/FitnessChart.svelte';
let { data } = $props();
const lang = $derived(detectFitnessLang($page.url.pathname));
const measureSlug = $derived(lang === 'en' ? 'measure' : 'messen');
const card = $derived(data.card);
const historyAsc = $derived(
[...(data.measurements ?? [])].sort(
(/** @type {any} */ a, /** @type {any} */ b) =>
new Date(a.date).getTime() - new Date(b.date).getTime()
)
);
const series = $derived.by(() => {
if (card.paired) {
/** @type {string[]} */
const dates = [];
/** @type {(number|null)[]} */
const left = [];
/** @type {(number|null)[]} */
const right = [];
for (const m of historyAsc) {
const ms = m.measurements ?? {};
const l = ms[card.dbLeft];
const r = ms[card.dbRight];
if (l == null && r == null) continue;
dates.push(m.date);
left.push(l ?? null);
right.push(r ?? null);
}
return { dates, left, right };
}
/** @type {string[]} */
const dates = [];
/** @type {number[]} */
const values = [];
for (const m of historyAsc) {
const ms = m.measurements ?? {};
const v = ms[card.db];
if (v == null) continue;
dates.push(m.date);
values.push(v);
}
return { dates, values };
});
const chartData = $derived.by(() => {
if (card.paired) {
return {
dates: series.dates,
labels: series.dates,
datasets: [
{ label: 'L', data: series.left, borderColor: '#88C0D0', pointBackgroundColor: '#88C0D0' },
{ label: 'R', data: series.right, borderColor: '#D08770', pointBackgroundColor: '#D08770' }
]
};
}
return {
dates: series.dates,
labels: series.dates,
datasets: [
{
label: t(card.labelKey, lang),
data: series.values,
borderColor: '#88C0D0',
pointBackgroundColor: '#88C0D0'
}
]
};
});
const stats = $derived.by(() => {
if (card.paired) {
const l = series.left.filter((/** @type {number|null} */ v) => v != null);
const r = series.right.filter((/** @type {number|null} */ v) => v != null);
const latest = {
left: l.length ? /** @type {number} */ (l[l.length - 1]) : null,
right: r.length ? /** @type {number} */ (r[r.length - 1]) : null
};
const first = {
left: l.length ? /** @type {number} */ (l[0]) : null,
right: r.length ? /** @type {number} */ (r[0]) : null
};
return { latest, first, count: series.dates.length };
}
const v = series.values;
return {
latest: v.length ? v[v.length - 1] : null,
first: v.length ? v[0] : null,
count: v.length,
min: v.length ? Math.min(...v) : null,
max: v.length ? Math.max(...v) : null
};
});
/** @param {number|null} a @param {number|null} b */
function delta(a, b) {
if (a == null || b == null) return null;
return a - b;
}
const hasData = $derived(series.dates.length > 0);
</script>
<svelte:head><title>{t(card.labelKey, lang)} · {lang === 'en' ? 'History' : 'Verlauf'} - Bocken</title></svelte:head>
<div class="detail-page">
<header class="detail-header">
<a class="back-link" href="/fitness/{measureSlug}" aria-label={t('back', lang)}>
<ArrowLeft size={18} />
</a>
<div class="head-text">
<span class="eyebrow">{t('body_parts', lang)}</span>
<h1>{t(card.labelKey, lang)}</h1>
</div>
<div class="head-img" aria-hidden="true">
{#if card.img && card.img.endsWith('.svg')}
<div
class="head-pic head-pic-svg"
style="--bp-svg-src: url(/fitness/measure/{card.img})"
></div>
{:else if card.img}
<img src="/fitness/measure/{card.img}" alt="" class="head-pic" />
{:else}
<Ruler size={40} strokeWidth={1.5} />
{/if}
</div>
</header>
{#if !hasData}
<div class="empty">
<p>{t('no_measurements_yet', lang)}</p>
<a class="cta" href="/fitness/{measureSlug}/body-parts">
<Ruler size={16} /> {t('measure_body_parts', lang)}
</a>
</div>
{:else}
<section class="summary">
{#if card.paired}
<div class="stat-grid">
<div class="stat">
<span class="stat-label">L · {t('latest', lang)}</span>
<span class="stat-value">
{stats.latest.left != null ? stats.latest.left.toFixed(1) : '—'}
<span class="stat-unit">cm</span>
</span>
{#if stats.latest.left != null && stats.first.left != null}
{@const d = delta(stats.latest.left, stats.first.left)}
{#if d != null && d !== 0}
<span class="stat-delta" class:up={d > 0} class:down={d < 0}>
{#if d > 0}<TrendingUp size={12} />{:else}<TrendingDown size={12} />{/if}
{Math.abs(d).toFixed(1)} cm
</span>
{:else}
<span class="stat-delta flat"><MinusIcon size={12} /> 0</span>
{/if}
{/if}
</div>
<div class="stat">
<span class="stat-label">R · {t('latest', lang)}</span>
<span class="stat-value">
{stats.latest.right != null ? stats.latest.right.toFixed(1) : '—'}
<span class="stat-unit">cm</span>
</span>
{#if stats.latest.right != null && stats.first.right != null}
{@const d = delta(stats.latest.right, stats.first.right)}
{#if d != null && d !== 0}
<span class="stat-delta" class:up={d > 0} class:down={d < 0}>
{#if d > 0}<TrendingUp size={12} />{:else}<TrendingDown size={12} />{/if}
{Math.abs(d).toFixed(1)} cm
</span>
{:else}
<span class="stat-delta flat"><MinusIcon size={12} /> 0</span>
{/if}
{/if}
</div>
</div>
{:else}
<div class="stat-grid">
<div class="stat">
<span class="stat-label">{t('latest', lang)}</span>
<span class="stat-value">
{stats.latest != null ? stats.latest.toFixed(1) : '—'}
<span class="stat-unit">cm</span>
</span>
{#if stats.latest != null && stats.first != null}
{@const d = delta(stats.latest, stats.first)}
{#if d != null && d !== 0}
<span class="stat-delta" class:up={d > 0} class:down={d < 0}>
{#if d > 0}<TrendingUp size={12} />{:else}<TrendingDown size={12} />{/if}
{Math.abs(d).toFixed(1)} cm
</span>
{:else}
<span class="stat-delta flat"><MinusIcon size={12} /> 0</span>
{/if}
{/if}
</div>
<div class="stat">
<span class="stat-label">min / max</span>
<span class="stat-value range">
{stats.min != null ? stats.min.toFixed(1) : '—'}
<span class="stat-unit"></span>
{stats.max != null ? stats.max.toFixed(1) : '—'}
<span class="stat-unit">cm</span>
</span>
</div>
</div>
{/if}
</section>
<section class="chart-wrap">
<FitnessChart data={chartData} yUnit=" cm" height="320px" />
</section>
{/if}
</div>
<style>
.detail-page {
display: flex;
flex-direction: column;
gap: 1.25rem;
max-width: 820px;
margin-inline: auto;
padding: 0.5rem 0;
}
.detail-header {
display: flex;
align-items: center;
gap: 0.85rem;
}
.back-link {
display: grid;
place-items: center;
width: 2.2rem;
height: 2.2rem;
border-radius: 50%;
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text-secondary);
text-decoration: none;
transition: all 150ms;
}
.back-link:hover {
color: var(--color-text-primary);
border-color: var(--color-text-tertiary);
}
.head-text {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.15rem;
min-width: 0;
}
.eyebrow {
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--color-text-tertiary);
}
h1 {
margin: 0;
font-size: 1.6rem;
letter-spacing: -0.01em;
}
.head-img {
display: grid;
place-items: center;
width: 3.5rem;
height: 3.5rem;
flex-shrink: 0;
border-radius: 50%;
background: var(--color-bg-secondary);
color: var(--color-text-secondary);
}
.head-pic {
width: 2.6rem;
height: 2.6rem;
object-fit: contain;
}
.head-pic-svg {
mask-image: var(--bp-svg-src);
-webkit-mask-image: var(--bp-svg-src);
mask-size: contain;
-webkit-mask-size: contain;
mask-repeat: no-repeat;
-webkit-mask-repeat: no-repeat;
mask-position: center;
-webkit-mask-position: center;
background-color: var(--color-text-primary);
}
@media (prefers-color-scheme: dark) {
img.head-pic { filter: invert(1); }
}
:global(:root[data-theme="dark"]) img.head-pic { filter: invert(1); }
:global(:root[data-theme="light"]) img.head-pic { filter: none; }
.summary .stat-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
.stat {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.85rem 1rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
}
.stat-label {
font-size: 0.68rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-text-secondary);
}
.stat-value {
font-size: 1.5rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
letter-spacing: -0.01em;
}
.stat-value.range {
font-size: 1.1rem;
}
.stat-unit {
font-size: 0.75rem;
color: var(--color-text-tertiary);
font-weight: 500;
margin-left: 0.15rem;
}
.stat-delta {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.72rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.stat-delta.up { color: var(--orange); }
.stat-delta.down { color: var(--green); }
.stat-delta.flat { color: var(--color-text-tertiary); }
.chart-wrap {
/* FitnessChart has its own card styling */
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.85rem;
padding: 2rem 1rem;
background: var(--color-surface);
border: 1px dashed var(--color-border);
border-radius: var(--radius-lg);
color: var(--color-text-secondary);
}
.cta {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 0.95rem;
border-radius: var(--radius-pill);
background: var(--color-primary);
color: var(--color-text-on-primary);
text-decoration: none;
font-weight: 600;
font-size: 0.9rem;
}
.cta:hover {
background: var(--color-primary-hover);
}
</style>