diff --git a/package.json b/package.json index 1418bdaf..91c11025 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "homepage", - "version": "1.37.7", + "version": "1.38.0", "private": true, "type": "module", "scripts": { diff --git a/src/lib/js/fitnessBodyParts.ts b/src/lib/js/fitnessBodyParts.ts new file mode 100644 index 00000000..14541b5e --- /dev/null +++ b/src/lib/js/fitnessBodyParts.ts @@ -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; +} diff --git a/src/routes/fitness/[measure=fitnessMeasure]/+page.server.ts b/src/routes/fitness/[measure=fitnessMeasure]/+page.server.ts index 6210ca00..dfaddaf0 100644 --- a/src/routes/fitness/[measure=fitnessMeasure]/+page.server.ts +++ b/src/routes/fitness/[measure=fitnessMeasure]/+page.server.ts @@ -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), diff --git a/src/routes/fitness/[measure=fitnessMeasure]/+page.svelte b/src/routes/fitness/[measure=fitnessMeasure]/+page.svelte index b3b98b52..8765fee9 100644 --- a/src/routes/fitness/[measure=fitnessMeasure]/+page.svelte +++ b/src/routes/fitness/[measure=fitnessMeasure]/+page.svelte @@ -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} - {#if bodyPartFields.some(f => f.value != null)} + {#if cardsWithData.length > 0}

{t('body_parts', lang)}

-
- {#each bodyPartFields.filter(f => f.value != null) as field} -
- {field.label} - {field.value} cm -
+
@@ -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 */ diff --git a/src/routes/fitness/[measure=fitnessMeasure]/[history=fitnessHistory]/[part]/+page.server.ts b/src/routes/fitness/[measure=fitnessMeasure]/[history=fitnessHistory]/[part]/+page.server.ts new file mode 100644 index 00000000..e5e44407 --- /dev/null +++ b/src/routes/fitness/[measure=fitnessMeasure]/[history=fitnessHistory]/[part]/+page.server.ts @@ -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 ?? [] + }; +}; diff --git a/src/routes/fitness/[measure=fitnessMeasure]/[history=fitnessHistory]/[part]/+page.svelte b/src/routes/fitness/[measure=fitnessMeasure]/[history=fitnessHistory]/[part]/+page.svelte new file mode 100644 index 00000000..e877605d --- /dev/null +++ b/src/routes/fitness/[measure=fitnessMeasure]/[history=fitnessHistory]/[part]/+page.svelte @@ -0,0 +1,383 @@ + + +{t(card.labelKey, lang)} · {lang === 'en' ? 'History' : 'Verlauf'} - Bocken + +
+
+ + + +
+ {t('body_parts', lang)} +

{t(card.labelKey, lang)}

+
+ +
+ + {#if !hasData} +
+

{t('no_measurements_yet', lang)}

+ + {t('measure_body_parts', lang)} + +
+ {:else} +
+ {#if card.paired} +
+
+ L · {t('latest', lang)} + + {stats.latest.left != null ? stats.latest.left.toFixed(1) : '—'} + cm + + {#if stats.latest.left != null && stats.first.left != null} + {@const d = delta(stats.latest.left, stats.first.left)} + {#if d != null && d !== 0} + 0} class:down={d < 0}> + {#if d > 0}{:else}{/if} + {Math.abs(d).toFixed(1)} cm + + {:else} + 0 + {/if} + {/if} +
+
+ R · {t('latest', lang)} + + {stats.latest.right != null ? stats.latest.right.toFixed(1) : '—'} + cm + + {#if stats.latest.right != null && stats.first.right != null} + {@const d = delta(stats.latest.right, stats.first.right)} + {#if d != null && d !== 0} + 0} class:down={d < 0}> + {#if d > 0}{:else}{/if} + {Math.abs(d).toFixed(1)} cm + + {:else} + 0 + {/if} + {/if} +
+
+ {:else} +
+
+ {t('latest', lang)} + + {stats.latest != null ? stats.latest.toFixed(1) : '—'} + cm + + {#if stats.latest != null && stats.first != null} + {@const d = delta(stats.latest, stats.first)} + {#if d != null && d !== 0} + 0} class:down={d < 0}> + {#if d > 0}{:else}{/if} + {Math.abs(d).toFixed(1)} cm + + {:else} + 0 + {/if} + {/if} +
+
+ min / max + + {stats.min != null ? stats.min.toFixed(1) : '—'} + + {stats.max != null ? stats.max.toFixed(1) : '—'} + cm + +
+
+ {/if} +
+ +
+ +
+ {/if} +
+ +