From 91e1efda6fd1c3c8b0440674ba3710da4534f909 Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Thu, 23 Apr 2026 13:35:39 +0200 Subject: [PATCH] feat(fitness/measure): consolidate entries by day + richer past-measurements summary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Server POST now upserts by (user, calendar day). Non-conflicting fields merge silently; real overwrites (new non-empty value ≠ stored value) return 409 with the conflict list. Client retries with `?overwrite=1` after a confirm dialog naming each field and its old→new value. Null/empty payload fields are skipped, so logging a body-fat entry on a day that already has weight merges cleanly without flagging a phantom weight conflict. - `summaryParts` in the history now includes a body-parts count, e.g. "86 kg · 0.1% bf · 5 body parts" or "5 body parts" instead of the flat "Body measurements only" fallback. Pluralised in EN and DE. - Inline quick-edit: "Full edit →" text replaced by a dashed primary pill `Pencil · Edit all fields · ChevronRight`, inlined with the X / ✓ action buttons on the same row. The label collapses to icons only at ≤480px so the three controls stay on one line. - Quick-edit date input swapped from native `` to the site's `DatePicker` component. - New i18n: `overwrite_title`, `overwrite_message`, `overwrite_confirm`. - TODO.md marks features #2 and #3 done. CLAUDE.md carries a policy note (no AI-attribution trailers on commits). --- CLAUDE.md | 60 +++++---- TODO.md | 8 ++ package.json | 2 +- src/lib/js/fitnessI18n.ts | 6 + .../api/fitness/measurements/+server.ts | 93 ++++++++++++-- .../[measure=fitnessMeasure]/+page.svelte | 120 ++++++++++++++---- .../body-parts/+page.svelte | 36 +++++- 7 files changed, 259 insertions(+), 66 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 195dd33f..79edfeaa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,25 @@ -You are able to use the Svelte MCP server, where you have access to comprehensive Svelte 5 and SvelteKit documentation. Here's how to use the available tools effectively: +# Repository Instructions +## Commits + +- **Never** append `Co-Authored-By: Claude ...` (or any similar AI-attribution trailer) to commit messages. Do not add it even if a default template or prior convention suggests it. +- Do not include "Generated with Claude Code" footers or similar watermarks in commit messages, PR bodies, or any files in this repo. + +### Versioning + +When committing, bump version numbers as appropriate using semver: + +- **patch** (x.y.Z): bug fixes, minor styling tweaks, small corrections +- **minor** (x.Y.0): new features, significant UI changes, new pages/routes +- **major** (X.0.0): breaking changes, major redesigns, data model changes + +Version files to update: +- `package.json` — site version (bump on every commit) +- `src-tauri/tauri.conf.json` + `src-tauri/Cargo.toml` — Tauri/Android app version. Only bump these when the Tauri app codebase itself changes (e.g. `src-tauri/` files), NOT for website-only changes. ## Available MCP Tools: +You are able to use the Svelte MCP server, where you have access to comprehensive Svelte 5 and SvelteKit documentation. Here's how to use the available tools effectively: + ### 1. list-sections Use this FIRST to discover all available documentation sections. Returns a structured list with titles, use_cases, and paths. @@ -30,9 +48,9 @@ Svelte 5 removed event modifiers like `on:click|preventDefault`. Use inline hand Generates a Svelte Playground link with the provided code. After completing the code, ask the user if they want a playground link. Only call this tool after user confirmation and NEVER if code was written to files in their project. -## Theming Rules +# Theming Rules -### Semantic CSS Variables (ALWAYS use these, NEVER hardcode Nord values for themed properties) +## Semantic CSS Variables (ALWAYS use these, NEVER hardcode Nord values for themed properties) | Purpose | Variable | Light resolves to | Dark resolves to | |---|---|---|---| @@ -46,22 +64,22 @@ After completing the code, ask the user if they want a playground link. Only cal | Tertiary text (descriptions) | `--color-text-tertiary` | nord2 | nord5 | | Borders | `--color-border` | nord4 | nord2/3 | -### What NOT to do +## What NOT to do - **NEVER** use `var(--nord0)` through `var(--nord6)` for backgrounds, text, or borders — these don't adapt to theme - **NEVER** write `@media (prefers-color-scheme: dark)` or `:global(:root[data-theme="dark"])` override blocks — semantic variables handle both themes automatically - **NEVER** use `var(--font-default-dark)` or `var(--accent-dark)` — these are legacy -### Primary interactive elements +## Primary interactive elements - Background: `var(--color-primary)` (nord10 light / nord8 dark) - Hover: `var(--color-primary-hover)` - Active: `var(--color-primary-active)` - Text on primary bg: `var(--color-text-on-primary)` -### Accent colors (OK to use directly, they work in both themes) +## Accent colors (OK to use directly, they work in both themes) - `var(--blue)`, `var(--red)`, `var(--green)`, `var(--orange)` — named accent colors - `var(--nord10)`, `var(--nord11)`, `var(--nord12)`, `var(--nord14)` — OK for hover states of accent-colored buttons only -### Chart.js theme reactivity +## Chart.js theme reactivity Charts don't use CSS variables. Use the `isDark()` pattern from `FitnessChart.svelte`: ```js function isDark() { @@ -74,58 +92,46 @@ const textColor = isDark() ? '#D8DEE9' : '#2E3440'; ``` Re-create the chart on theme change via `MutationObserver` on `data-theme` + `matchMedia` listener. -### Form inputs +## Form inputs - Background: `var(--color-bg-tertiary)` - Border: `var(--color-border)` - Text: `var(--color-text-primary)` - Label: `var(--color-text-secondary)` -### Toggle component +## Toggle component Use `Toggle.svelte` (iOS-style) instead of raw `` for user-facing boolean switches. ## Site-Wide Design Language -### Layout & Spacing +## Layout & Spacing - Max content width: `1000px`–`1200px` with `margin-inline: auto` - Card/grid gaps: `2rem` desktop, `1rem` tablet, `0.5rem` mobile - Breakpoints: `410px` (small mobile), `560px` (tablet), `900px` (rosary), `1024px` (desktop) -### Border Radius Tokens +## Border Radius Tokens - `--radius-pill: 1000px` — nav bar, pill buttons - `--radius-card: 20px` — major cards (recipe cards) - `--radius-lg: 0.75rem` — medium rounded elements - `--radius-md: 0.5rem` — standard rounding - `--radius-sm: 0.3rem` — small elements -### Shadow Tokens +## Shadow Tokens - `--shadow-sm` / `--shadow-md` / `--shadow-lg` / `--shadow-hover` — use these, don't hardcode - Shadows are spread-based (`0 0 Xem Yem`) not offset-based -### Hover & Interaction Patterns +## Hover & Interaction Patterns - Cards/links: `scale: 1.02` + shadow elevation on hover - Tags/pills: `scale: 1.05` with `--transition-fast` (100ms) - Standard transitions: `--transition-normal` (200ms) - Nav bar: glassmorphism (`backdrop-filter: blur(16px)`, semi-transparent bg) -### Typography +## Typography - Font stack: Helvetica, Arial, "Noto Sans", sans-serif - Size tokens: `--text-sm` through `--text-3xl` - Headings in grids: `1.5rem` desktop → `1.2rem` tablet → `0.95rem` mobile -### Surfaces & Cards +## Surfaces & Cards - Use `--color-surface` / `--color-surface-hover` for card backgrounds - Use `--color-bg-elevated` for hover/active states - Recipe cards: 300px wide, `--radius-card` corners - Global utility classes: `.g-icon-badge` (circular), `.g-pill` (pill-shaped) - -## Versioning - -When committing, bump version numbers as appropriate using semver: - -- **patch** (x.y.Z): bug fixes, minor styling tweaks, small corrections -- **minor** (x.Y.0): new features, significant UI changes, new pages/routes -- **major** (X.0.0): breaking changes, major redesigns, data model changes - -Version files to update: -- `package.json` — site version (bump on every commit) -- `src-tauri/tauri.conf.json` + `src-tauri/Cargo.toml` — Tauri/Android app version. Only bump these when the Tauri app codebase itself changes (e.g. `src-tauri/` files), NOT for website-only changes. diff --git a/TODO.md b/TODO.md index 690a6228..e4271852 100644 --- a/TODO.md +++ b/TODO.md @@ -1,5 +1,13 @@ # TODO +## Features +[x] on /fitness/measure, fill "Past measurements" in SSR only for the last 10 measurements. anything further should be fetched client side on mount to decreae initial page load time. use a "show more" button and paginate measurments. +[x] on /fitness/measure (resp. their associated logging API routes), consolidate measurements by day. If we want to log another measurement, overwriting an old one, show a warning to indicate this. disparate measurements (e.g., weight and bodyfat) should not show this warning but simply be merged into one log entry for that day. +[x] on /fitness/measure in the past measurments tab, show more than "Body measurements only" if we don't have Bodyweight logged. we can be a bit more elaborate in our syntax here tbh. +[ ] add a button on /fitness/measure/body-parts for each measurement directly below to say "Same value", instead of having to hit +, then - to lock in same number +[ ] BF graph (with trend line like weight graph) on /fitness/stats page. Emphasize relative changes, not absolute numbers in design (as we cannot trust those) (e.g., use start day of overview as 0% and then show +/- x % on the graph) +[ ] Workshop better names than "Measure" for the /fitness/measure route. It's about body data points (i.e., non-food related). What's a better, short name than "Measure" to capture the logging of weight, body composition, body part measurements, and period tracking? + ## Refactor Recipe Search Component Refactor `src/lib/components/Search.svelte` to use the new `SearchInput.svelte` component for the visual input part. This will: diff --git a/package.json b/package.json index c66c6927..ebd55080 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "homepage", - "version": "1.46.4", + "version": "1.46.5", "private": true, "type": "module", "scripts": { diff --git a/src/lib/js/fitnessI18n.ts b/src/lib/js/fitnessI18n.ts index 31990b67..ec54a143 100644 --- a/src/lib/js/fitnessI18n.ts +++ b/src/lib/js/fitnessI18n.ts @@ -313,6 +313,12 @@ const translations: Translations = { history: { en: 'History', de: 'Verlauf' }, past_measurements: { en: 'Past measurements', de: 'Frühere Messungen' }, show_more: { en: 'Show more', de: 'Mehr anzeigen' }, + overwrite_title: { en: 'Overwrite existing values?', de: 'Bestehende Werte überschreiben?' }, + overwrite_message: { + en: 'You already have values for this date: {fields}. Replace them?', + de: 'Für dieses Datum sind bereits Werte erfasst: {fields}. Überschreiben?' + }, + overwrite_confirm: { en: 'Overwrite', de: 'Überschreiben' }, // SetTable set_header: { en: 'SET', de: 'SATZ' }, diff --git a/src/routes/api/fitness/measurements/+server.ts b/src/routes/api/fitness/measurements/+server.ts index f3978a4d..f7917750 100644 --- a/src/routes/api/fitness/measurements/+server.ts +++ b/src/routes/api/fitness/measurements/+server.ts @@ -30,23 +30,92 @@ export const GET: RequestHandler = async ({ url, locals }) => { return json({ measurements, total, limit, offset }); }; -export const POST: RequestHandler = async ({ request, locals }) => { +export const POST: RequestHandler = async ({ request, locals, url }) => { const user = await requireAuth(locals); await dbConnect(); const data = await request.json(); - const { date, weight, bodyFatPercent, caloricIntake, measurements, notes } = data; + const { date, weight, bodyFatPercent, caloricIntake, measurements: bp, notes } = data; + const overwrite = url.searchParams.get('overwrite') === '1'; - const measurement = new BodyMeasurement({ - date: date ? new Date(date) : new Date(), - weight, - bodyFatPercent, - caloricIntake, - measurements, - notes, - createdBy: user.nickname + const target = date ? new Date(date) : new Date(); + const dayStart = new Date(Date.UTC(target.getUTCFullYear(), target.getUTCMonth(), target.getUTCDate())); + const dayEnd = new Date(dayStart.getTime() + 24 * 60 * 60 * 1000); + + const existing = await BodyMeasurement.findOne({ + createdBy: user.nickname, + date: { $gte: dayStart, $lt: dayEnd } }); - await measurement.save(); - return json({ measurement }, { status: 201 }); + if (!existing) { + const doc = new BodyMeasurement({ + date: dayStart, + weight, + bodyFatPercent, + caloricIntake, + measurements: bp, + notes, + createdBy: user.nickname + }); + await doc.save(); + return json({ measurement: doc, merged: false }, { status: 201 }); + } + + type Conflict = { key: string; oldVal: unknown; newVal: unknown }; + const conflicts: Conflict[] = []; + const merges: Record = {}; + const existingDoc = existing; + + /** Top-level field: add if missing, conflict if present and different. Null/empty = "no value supplied". */ + function check(key: 'weight' | 'bodyFatPercent' | 'caloricIntake' | 'notes', newVal: unknown) { + if (newVal == null || newVal === '') return; + const oldVal = existingDoc[key]; + if (oldVal == null || oldVal === '') { + merges[key] = newVal; + } else if (newVal !== oldVal) { + conflicts.push({ key, oldVal, newVal }); + } + } + + check('weight', weight); + check('bodyFatPercent', bodyFatPercent); + check('caloricIntake', caloricIntake); + check('notes', notes); + + /** Body-parts sub-object: check each field independently */ + let mergedBp: Record | undefined; + if (bp && typeof bp === 'object') { + const existingBp = (existing.measurements ?? {}) as Record; + mergedBp = { ...existingBp }; + for (const [partKey, partVal] of Object.entries(bp)) { + if (partVal == null) continue; + const oldPart = existingBp[partKey]; + if (oldPart == null) { + mergedBp[partKey] = partVal; + } else if (oldPart !== partVal) { + conflicts.push({ key: `measurements.${partKey}`, oldVal: oldPart, newVal: partVal }); + } + } + } + + if (conflicts.length > 0 && !overwrite) { + return json({ conflicts, existingId: existing._id }, { status: 409 }); + } + + if (overwrite && conflicts.length > 0) { + for (const c of conflicts) { + if (c.key.startsWith('measurements.')) { + const partKey = c.key.slice('measurements.'.length); + if (!mergedBp) mergedBp = { ...((existing.measurements ?? {}) as Record) }; + mergedBp[partKey] = c.newVal; + } else { + merges[c.key] = c.newVal; + } + } + } + + Object.assign(existing, merges); + if (mergedBp !== undefined) existing.measurements = mergedBp; + await existing.save(); + return json({ measurement: existing, merged: true }); }; diff --git a/src/routes/fitness/[measure=fitnessMeasure]/+page.svelte b/src/routes/fitness/[measure=fitnessMeasure]/+page.svelte index af76d8cb..242f066e 100644 --- a/src/routes/fitness/[measure=fitnessMeasure]/+page.svelte +++ b/src/routes/fitness/[measure=fitnessMeasure]/+page.svelte @@ -160,7 +160,15 @@ const parts = []; if (m.weight != null) parts.push(`${m.weight} kg`); if (m.bodyFatPercent != null) parts.push(`${m.bodyFatPercent}% bf`); - return parts.join(' · ') || t('body_measurements_only', lang); + const bpCount = m.measurements + ? Object.values(m.measurements).filter((v) => v != null).length + : 0; + if (bpCount > 0) { + const en = bpCount === 1 ? '1 body part' : `${bpCount} body parts`; + const de = bpCount === 1 ? '1 Körperteil' : `${bpCount} Körperteile`; + parts.push(lang === 'en' ? en : de); + } + return parts.join(' · ') || t('no_measurements_yet', lang); } // --- New measurement form --- @@ -318,23 +326,72 @@ formDate = new Date().toISOString().slice(0, 10); } + /** + * Render a conflict list for the overwrite dialog. + * @param {Array<{ key: string, oldVal: unknown, newVal: unknown }>} conflicts + */ + function formatConflicts(conflicts) { + /** @type {Record} */ + const partKeyMap = { + leftBicep: 'l_bicep', rightBicep: 'r_bicep', + leftForearm: 'l_forearm', rightForearm: 'r_forearm', + leftThigh: 'l_thigh', rightThigh: 'r_thigh', + leftCalf: 'l_calf', rightCalf: 'r_calf' + }; + return conflicts.map((c) => { + let label = c.key; + let unit = ''; + if (c.key === 'weight') { label = lang === 'en' ? 'Weight' : 'Gewicht'; unit = ' kg'; } + else if (c.key === 'bodyFatPercent') { label = lang === 'en' ? 'Body Fat' : 'Körperfett'; unit = ' %'; } + else if (c.key === 'caloricIntake') { label = lang === 'en' ? 'Calories' : 'Kalorien'; unit = ' kcal'; } + else if (c.key === 'notes') { label = lang === 'en' ? 'Notes' : 'Notizen'; } + else if (c.key.startsWith('measurements.')) { + const part = c.key.slice('measurements.'.length); + label = t(partKeyMap[part] ?? part, lang); + unit = ' cm'; + } + return `${label} (${c.oldVal}${unit} → ${c.newVal}${unit})`; + }).join(', '); + } + async function saveMeasurement() { saving = true; try { - const res = await fetch('/api/fitness/measurements', { + const body = buildBody(); + /** @param {boolean} overwrite */ + const doPost = (overwrite) => fetch(`/api/fitness/measurements${overwrite ? '?overwrite=1' : ''}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(buildBody()) + body: JSON.stringify(body) }); + let res = await doPost(false); + if (res.status === 409) { + const { conflicts } = await res.json(); + const ok = await confirm( + t('overwrite_message', lang).replace('{fields}', formatConflicts(conflicts)), + { + title: t('overwrite_title', lang), + confirmText: t('overwrite_confirm', lang), + cancelText: t('cancel', lang), + destructive: true + } + ); + if (!ok) { saving = false; return; } + res = await doPost(true); + } if (res.ok) { - const created = await res.json(); - // Refresh latest and prepend to history + const payload = await res.json(); + const saved = payload.measurement ?? payload; try { const latestRes = await fetch('/api/fitness/measurements/latest'); if (latestRes.ok) latest = await latestRes.json(); } catch {} - measurements = [created.measurement ?? created, ...measurements]; - measurementsTotal = measurementsTotal + 1; + if (payload.merged) { + measurements = measurements.map((/** @type {any} */ m) => m._id === saved._id ? saved : m); + } else { + measurements = [saved, ...measurements]; + measurementsTotal = measurementsTotal + 1; + } resetForm(); toast.success(lang === 'en' ? 'Measurement saved' : 'Messung gespeichert'); } else { @@ -536,12 +593,9 @@
{#if editingId === m._id} {:else}
@@ -1283,9 +1339,9 @@ outline: none; border-color: var(--color-primary); } - .edit-input.edit-date { - flex: 1 1 120px; - min-width: 110px; + .edit-date-wrap { + flex: 1 1 140px; + min-width: 130px; } .edit-num { display: inline-flex; @@ -1338,13 +1394,29 @@ color: var(--color-text-primary); } .edit-more { - flex-basis: 100%; - font-size: 0.68rem; - color: var(--color-text-tertiary); + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.3rem 0.7rem; + border: 1px dashed color-mix(in oklab, var(--color-primary) 40%, transparent); + border-radius: var(--radius-pill); + background: color-mix(in oklab, var(--color-primary) 5%, transparent); + color: var(--color-text-secondary); + font-size: 0.7rem; + font-weight: 600; text-decoration: none; - padding-top: 0.1rem; + white-space: nowrap; + transition: border-color var(--transition-fast, 120ms), background var(--transition-fast, 120ms), color var(--transition-fast, 120ms); + } + .edit-more:hover { + border-style: solid; + border-color: var(--color-primary); + background: color-mix(in oklab, var(--color-primary) 10%, transparent); + color: var(--color-primary); + } + @media (max-width: 480px) { + .edit-more-label { display: none; } } - .edit-more:hover { color: var(--color-primary); } /* Desktop 2-col layout */ @media (min-width: 1024px) { diff --git a/src/routes/fitness/[measure=fitnessMeasure]/body-parts/+page.svelte b/src/routes/fitness/[measure=fitnessMeasure]/body-parts/+page.svelte index 4c70fda6..9f780cb7 100644 --- a/src/routes/fitness/[measure=fitnessMeasure]/body-parts/+page.svelte +++ b/src/routes/fitness/[measure=fitnessMeasure]/body-parts/+page.svelte @@ -6,6 +6,7 @@ import { cubicOut } from 'svelte/easing'; import { detectFitnessLang, t } from '$lib/js/fitnessI18n'; import { toast } from '$lib/js/toast.svelte'; + import { confirm } from '$lib/js/confirmDialog.svelte'; import DatePicker from '$lib/components/DatePicker.svelte'; import SaveFab from '$lib/components/SaveFab.svelte'; import Toggle from '$lib/components/Toggle.svelte'; @@ -188,11 +189,42 @@ } saving = true; try { - const res = await fetch('/api/fitness/measurements', { + const body = { date: formDate, measurements: ms }; + /** @param {boolean} overwrite */ + const doPost = (overwrite) => fetch(`/api/fitness/measurements${overwrite ? '?overwrite=1' : ''}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ date: formDate, measurements: ms }) + body: JSON.stringify(body) }); + let res = await doPost(false); + if (res.status === 409) { + const { conflicts } = await res.json(); + /** @type {Record} */ + const partKeyMap = { + leftBicep: 'l_bicep', rightBicep: 'r_bicep', + leftForearm: 'l_forearm', rightForearm: 'r_forearm', + leftThigh: 'l_thigh', rightThigh: 'r_thigh', + leftCalf: 'l_calf', rightCalf: 'r_calf' + }; + /** @param {{ key: string, oldVal: unknown, newVal: unknown }} c */ + const fmtConflict = (c) => { + const part = c.key.startsWith('measurements.') ? c.key.slice('measurements.'.length) : c.key; + const label = t(partKeyMap[part] ?? part, lang); + return `${label} (${c.oldVal} cm → ${c.newVal} cm)`; + }; + const fields = conflicts.map(fmtConflict).join(', '); + const ok = await confirm( + t('overwrite_message', lang).replace('{fields}', fields), + { + title: t('overwrite_title', lang), + confirmText: t('overwrite_confirm', lang), + cancelText: t('cancel', lang), + destructive: true + } + ); + if (!ok) { saving = false; return; } + res = await doPost(true); + } if (res.ok) { toast.success(lang === 'en' ? 'Measurement saved' : 'Messung gespeichert'); await goto(`/fitness/${measureSlug}`);