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:
+1
-1
@@ -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": {
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
+16
@@ -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 ?? []
|
||||||
|
};
|
||||||
|
};
|
||||||
+383
@@ -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>
|
||||||
Reference in New Issue
Block a user