refactor(fitness): move body-parts overview to stats page

Body-parts grid now lives on /fitness/stats, and per-part history
pages moved from /fitness/measure/history/<part> to
/fitness/stats/history/<part>. Measure page keeps weight/BF entry
and the body-parts measure launcher.
This commit is contained in:
2026-04-21 12:12:22 +02:00
parent 7f4b65f009
commit df36e285ac
6 changed files with 236 additions and 227 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "homepage", "name": "homepage",
"version": "1.40.3", "version": "1.40.4",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -6,11 +6,9 @@
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';
@@ -99,26 +97,6 @@
{ 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;
@@ -340,55 +318,6 @@
{/if} {/if}
</form> </form>
{#if cardsWithData.length > 0}
<section class="body-parts-section">
<h2>{t('body_parts', lang)}</h2>
<div class="bp-grid">
{#each cardsWithData as card (card.key)}
{@const cv = currentValue(card)}
<a
class="bp-card"
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}
</div>
</section>
{/if}
{#if measurements.length > 0} {#if measurements.length > 0}
<section class="history-section"> <section class="history-section">
<button class="history-toggle" onclick={() => showWeightHistory = !showWeightHistory}> <button class="history-toggle" onclick={() => showWeightHistory = !showWeightHistory}>
@@ -919,156 +848,6 @@
white-space: nowrap; white-space: nowrap;
} }
/* Body parts (latest) */
.bp-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.6rem;
}
.bp-card {
display: flex;
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);
}
.bp-card:hover {
border-color: var(--color-primary);
box-shadow: var(--shadow-sm);
}
.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);
}
.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;
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 */
.history-toggle { .history-toggle {
display: flex; display: flex;
@@ -2,15 +2,17 @@ import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ fetch, locals }) => { export const load: PageServerLoad = async ({ fetch, locals }) => {
const session = await locals.auth(); const session = await locals.auth();
const [res, goalRes, heatmapRes, nutritionRes] = await Promise.all([ const [res, goalRes, heatmapRes, nutritionRes, latestRes] = await Promise.all([
fetch('/api/fitness/stats/overview'), fetch('/api/fitness/stats/overview'),
fetch('/api/fitness/goal'), fetch('/api/fitness/goal'),
fetch('/api/fitness/stats/muscle-heatmap?weeks=8'), fetch('/api/fitness/stats/muscle-heatmap?weeks=8'),
fetch('/api/fitness/stats/nutrition') fetch('/api/fitness/stats/nutrition'),
fetch('/api/fitness/measurements/latest')
]); ]);
const stats = await res.json(); const stats = await res.json();
const goal = goalRes.ok ? await goalRes.json() : { weeklyWorkouts: null, streak: 0 }; const goal = goalRes.ok ? await goalRes.json() : { weeklyWorkouts: null, streak: 0 };
const muscleHeatmap = heatmapRes.ok ? await heatmapRes.json() : { weeks: [], totals: {}, muscleGroups: [] }; const muscleHeatmap = heatmapRes.ok ? await heatmapRes.json() : { weeks: [], totals: {}, muscleGroups: [] };
const nutritionStats = nutritionRes.ok ? await nutritionRes.json() : null; const nutritionStats = nutritionRes.ok ? await nutritionRes.json() : null;
return { session, stats, goal, muscleHeatmap, nutritionStats }; const latest = latestRes.ok ? await latestRes.json() : {};
return { session, stats, goal, muscleHeatmap, nutritionStats, latest };
}; };
@@ -3,14 +3,17 @@
import { page } from '$app/stores'; import { page } from '$app/stores';
import FitnessChart from '$lib/components/fitness/FitnessChart.svelte'; import FitnessChart from '$lib/components/fitness/FitnessChart.svelte';
import MuscleHeatmap from '$lib/components/fitness/MuscleHeatmap.svelte'; import MuscleHeatmap from '$lib/components/fitness/MuscleHeatmap.svelte';
import { Dumbbell, Route, Flame, Weight, Beef, Droplet, Wheat, Scale, Target, Info } from '@lucide/svelte'; import { Dumbbell, Route, Flame, Weight, Beef, Droplet, Wheat, Scale, Target, Info, Ruler } from '@lucide/svelte';
import FitnessStreakAura from '$lib/components/fitness/FitnessStreakAura.svelte'; import FitnessStreakAura from '$lib/components/fitness/FitnessStreakAura.svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n'; import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
import { toast } from '$lib/js/toast.svelte'; import { toast } from '$lib/js/toast.svelte';
import StatsRingGraph from '$lib/components/fitness/StatsRingGraph.svelte'; import StatsRingGraph from '$lib/components/fitness/StatsRingGraph.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 statsSlug = $derived(lang === 'en' ? 'stats' : 'statistik');
const historySlug = $derived(lang === 'en' ? 'history' : 'verlauf');
let { data } = $props(); let { data } = $props();
@@ -37,6 +40,28 @@
const stats = $derived(data.stats ?? {}); const stats = $derived(data.stats ?? {});
const latestBp = $derived(data.latest?.measurements?.value ?? {});
/** @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));
let goalStreak = $derived(data.goal?.streak ?? 0); let goalStreak = $derived(data.goal?.streak ?? 0);
let goalWeekly = $derived(data.goal?.weeklyWorkouts ?? null); let goalWeekly = $derived(data.goal?.weeklyWorkouts ?? null);
let showBalanceInfo = $state(false); let showBalanceInfo = $state(false);
@@ -356,6 +381,55 @@
<MuscleHeatmap data={data.muscleHeatmap} /> <MuscleHeatmap data={data.muscleHeatmap} />
</div> </div>
</div> </div>
{#if cardsWithData.length > 0}
<section class="body-parts-section">
<h2>{t('body_parts', lang)}</h2>
<div class="bp-grid">
{#each cardsWithData as card (card.key)}
{@const cv = currentValue(card)}
<a
class="bp-card"
href="/fitness/{statsSlug}/{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}
</div>
</section>
{/if}
</div> </div>
<style> <style>
@@ -890,4 +964,157 @@
font-size: 1rem; font-size: 1rem;
font-weight: 700; font-weight: 700;
} }
.body-parts-section h2 {
margin: 0 0 0.5rem;
font-size: 1.1rem;
}
.bp-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.6rem;
}
.bp-card {
display: flex;
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);
}
.bp-card:hover {
border-color: var(--color-primary);
box-shadow: var(--shadow-sm);
}
.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);
}
.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;
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;
}
}
</style> </style>
@@ -7,6 +7,7 @@
let { data } = $props(); let { data } = $props();
const lang = $derived(detectFitnessLang($page.url.pathname)); const lang = $derived(detectFitnessLang($page.url.pathname));
const statsSlug = $derived(lang === 'en' ? 'stats' : 'statistik');
const measureSlug = $derived(lang === 'en' ? 'measure' : 'messen'); const measureSlug = $derived(lang === 'en' ? 'measure' : 'messen');
const card = $derived(data.card); const card = $derived(data.card);
@@ -112,7 +113,7 @@
<div class="detail-page"> <div class="detail-page">
<header class="detail-header"> <header class="detail-header">
<a class="back-link" href="/fitness/{measureSlug}" aria-label={t('back', lang)}> <a class="back-link" href="/fitness/{statsSlug}" aria-label={t('back', lang)}>
<ArrowLeft size={18} /> <ArrowLeft size={18} />
</a> </a>
<div class="head-text"> <div class="head-text">