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",
|
||||
"version": "1.37.7",
|
||||
"version": "1.38.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"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 }) => {
|
||||
const [latestRes, listRes, goalRes, periodRes, shareRes, sharedRes] = await Promise.all([
|
||||
fetch('/api/fitness/measurements/latest'),
|
||||
fetch('/api/fitness/measurements?limit=20'),
|
||||
fetch('/api/fitness/measurements?limit=200'),
|
||||
fetch('/api/fitness/goal'),
|
||||
fetch('/api/fitness/period').catch(() => null),
|
||||
fetch('/api/fitness/period/share').catch(() => null),
|
||||
|
||||
@@ -6,9 +6,11 @@
|
||||
import { confirm } from '$lib/js/confirmDialog.svelte';
|
||||
import SaveFab from '$lib/components/SaveFab.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 measureSlug = $derived(lang === 'en' ? 'measure' : 'messen');
|
||||
const historySlug = $derived(lang === 'en' ? 'history' : 'verlauf');
|
||||
import { getWorkout } from '$lib/js/workout.svelte';
|
||||
import PeriodTracker from '$lib/components/fitness/PeriodTracker.svelte';
|
||||
|
||||
@@ -94,6 +96,26 @@
|
||||
{ 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 */
|
||||
async function deleteMeasurement(id) {
|
||||
if (!await confirm(t('delete_measurement_confirm', lang))) return;
|
||||
@@ -318,15 +340,50 @@
|
||||
{/if}
|
||||
</form>
|
||||
|
||||
{#if bodyPartFields.some(f => f.value != null)}
|
||||
{#if cardsWithData.length > 0}
|
||||
<section class="body-parts-section">
|
||||
<h2>{t('body_parts', lang)}</h2>
|
||||
<div class="body-grid">
|
||||
{#each bodyPartFields.filter(f => f.value != null) as field}
|
||||
<div class="body-row">
|
||||
<span class="body-label">{field.label}</span>
|
||||
<span class="body-value">{field.value} cm</span>
|
||||
</div>
|
||||
<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>
|
||||
@@ -765,22 +822,153 @@
|
||||
}
|
||||
|
||||
/* Body parts (latest) */
|
||||
.body-grid {
|
||||
.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);
|
||||
}
|
||||
.body-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: 0.85rem;
|
||||
.bp-card:hover {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.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);
|
||||
}
|
||||
.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;
|
||||
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 */
|
||||
|
||||
+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