feat(fitness/measure): consolidate entries by day + richer past-measurements summary

- 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 `<input type="date">`
  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).
This commit is contained in:
2026-04-23 13:35:39 +02:00
parent 6d3165f405
commit 91e1efda6f
7 changed files with 259 additions and 66 deletions
+33 -27
View File
@@ -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: ## 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 ### 1. list-sections
Use this FIRST to discover all available documentation sections. Returns a structured list with titles, use_cases, and paths. 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. 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. 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 | | 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 | | Tertiary text (descriptions) | `--color-text-tertiary` | nord2 | nord5 |
| Borders | `--color-border` | nord4 | nord2/3 | | 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** 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** 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 - **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) - Background: `var(--color-primary)` (nord10 light / nord8 dark)
- Hover: `var(--color-primary-hover)` - Hover: `var(--color-primary-hover)`
- Active: `var(--color-primary-active)` - Active: `var(--color-primary-active)`
- Text on primary bg: `var(--color-text-on-primary)` - 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(--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 - `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`: Charts don't use CSS variables. Use the `isDark()` pattern from `FitnessChart.svelte`:
```js ```js
function isDark() { 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. Re-create the chart on theme change via `MutationObserver` on `data-theme` + `matchMedia` listener.
### Form inputs ## Form inputs
- Background: `var(--color-bg-tertiary)` - Background: `var(--color-bg-tertiary)`
- Border: `var(--color-border)` - Border: `var(--color-border)`
- Text: `var(--color-text-primary)` - Text: `var(--color-text-primary)`
- Label: `var(--color-text-secondary)` - Label: `var(--color-text-secondary)`
### Toggle component ## Toggle component
Use `Toggle.svelte` (iOS-style) instead of raw `<input type="checkbox">` for user-facing boolean switches. Use `Toggle.svelte` (iOS-style) instead of raw `<input type="checkbox">` for user-facing boolean switches.
## Site-Wide Design Language ## Site-Wide Design Language
### Layout & Spacing ## Layout & Spacing
- Max content width: `1000px``1200px` with `margin-inline: auto` - Max content width: `1000px``1200px` with `margin-inline: auto`
- Card/grid gaps: `2rem` desktop, `1rem` tablet, `0.5rem` mobile - Card/grid gaps: `2rem` desktop, `1rem` tablet, `0.5rem` mobile
- Breakpoints: `410px` (small mobile), `560px` (tablet), `900px` (rosary), `1024px` (desktop) - Breakpoints: `410px` (small mobile), `560px` (tablet), `900px` (rosary), `1024px` (desktop)
### Border Radius Tokens ## Border Radius Tokens
- `--radius-pill: 1000px` — nav bar, pill buttons - `--radius-pill: 1000px` — nav bar, pill buttons
- `--radius-card: 20px` — major cards (recipe cards) - `--radius-card: 20px` — major cards (recipe cards)
- `--radius-lg: 0.75rem` — medium rounded elements - `--radius-lg: 0.75rem` — medium rounded elements
- `--radius-md: 0.5rem` — standard rounding - `--radius-md: 0.5rem` — standard rounding
- `--radius-sm: 0.3rem` — small elements - `--radius-sm: 0.3rem` — small elements
### Shadow Tokens ## Shadow Tokens
- `--shadow-sm` / `--shadow-md` / `--shadow-lg` / `--shadow-hover` — use these, don't hardcode - `--shadow-sm` / `--shadow-md` / `--shadow-lg` / `--shadow-hover` — use these, don't hardcode
- Shadows are spread-based (`0 0 Xem Yem`) not offset-based - 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 - Cards/links: `scale: 1.02` + shadow elevation on hover
- Tags/pills: `scale: 1.05` with `--transition-fast` (100ms) - Tags/pills: `scale: 1.05` with `--transition-fast` (100ms)
- Standard transitions: `--transition-normal` (200ms) - Standard transitions: `--transition-normal` (200ms)
- Nav bar: glassmorphism (`backdrop-filter: blur(16px)`, semi-transparent bg) - Nav bar: glassmorphism (`backdrop-filter: blur(16px)`, semi-transparent bg)
### Typography ## Typography
- Font stack: Helvetica, Arial, "Noto Sans", sans-serif - Font stack: Helvetica, Arial, "Noto Sans", sans-serif
- Size tokens: `--text-sm` through `--text-3xl` - Size tokens: `--text-sm` through `--text-3xl`
- Headings in grids: `1.5rem` desktop → `1.2rem` tablet → `0.95rem` mobile - 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-surface` / `--color-surface-hover` for card backgrounds
- Use `--color-bg-elevated` for hover/active states - Use `--color-bg-elevated` for hover/active states
- Recipe cards: 300px wide, `--radius-card` corners - Recipe cards: 300px wide, `--radius-card` corners
- Global utility classes: `.g-icon-badge` (circular), `.g-pill` (pill-shaped) - 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.
+8
View File
@@ -1,5 +1,13 @@
# TODO # 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 Recipe Search Component
Refactor `src/lib/components/Search.svelte` to use the new `SearchInput.svelte` component for the visual input part. This will: Refactor `src/lib/components/Search.svelte` to use the new `SearchInput.svelte` component for the visual input part. This will:
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "homepage", "name": "homepage",
"version": "1.46.4", "version": "1.46.5",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+6
View File
@@ -313,6 +313,12 @@ const translations: Translations = {
history: { en: 'History', de: 'Verlauf' }, history: { en: 'History', de: 'Verlauf' },
past_measurements: { en: 'Past measurements', de: 'Frühere Messungen' }, past_measurements: { en: 'Past measurements', de: 'Frühere Messungen' },
show_more: { en: 'Show more', de: 'Mehr anzeigen' }, 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 // SetTable
set_header: { en: 'SET', de: 'SATZ' }, set_header: { en: 'SET', de: 'SATZ' },
+81 -12
View File
@@ -30,23 +30,92 @@ export const GET: RequestHandler = async ({ url, locals }) => {
return json({ measurements, total, limit, offset }); 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); const user = await requireAuth(locals);
await dbConnect(); await dbConnect();
const data = await request.json(); 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({ const target = date ? new Date(date) : new Date();
date: date ? new Date(date) : new Date(), const dayStart = new Date(Date.UTC(target.getUTCFullYear(), target.getUTCMonth(), target.getUTCDate()));
weight, const dayEnd = new Date(dayStart.getTime() + 24 * 60 * 60 * 1000);
bodyFatPercent,
caloricIntake, const existing = await BodyMeasurement.findOne({
measurements, createdBy: user.nickname,
notes, date: { $gte: dayStart, $lt: dayEnd }
createdBy: user.nickname
}); });
await measurement.save(); if (!existing) {
return json({ measurement }, { status: 201 }); 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<string, unknown> = {};
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<string, unknown> | undefined;
if (bp && typeof bp === 'object') {
const existingBp = (existing.measurements ?? {}) as Record<string, unknown>;
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<string, unknown>) };
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 });
}; };
@@ -160,7 +160,15 @@
const parts = []; const parts = [];
if (m.weight != null) parts.push(`${m.weight} kg`); if (m.weight != null) parts.push(`${m.weight} kg`);
if (m.bodyFatPercent != null) parts.push(`${m.bodyFatPercent}% bf`); 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 --- // --- New measurement form ---
@@ -318,23 +326,72 @@
formDate = new Date().toISOString().slice(0, 10); 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<string, string>} */
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() { async function saveMeasurement() {
saving = true; saving = true;
try { 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', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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) { if (res.ok) {
const created = await res.json(); const payload = await res.json();
// Refresh latest and prepend to history const saved = payload.measurement ?? payload;
try { try {
const latestRes = await fetch('/api/fitness/measurements/latest'); const latestRes = await fetch('/api/fitness/measurements/latest');
if (latestRes.ok) latest = await latestRes.json(); if (latestRes.ok) latest = await latestRes.json();
} catch {} } catch {}
measurements = [created.measurement ?? created, ...measurements]; if (payload.merged) {
measurementsTotal = measurementsTotal + 1; measurements = measurements.map((/** @type {any} */ m) => m._id === saved._id ? saved : m);
} else {
measurements = [saved, ...measurements];
measurementsTotal = measurementsTotal + 1;
}
resetForm(); resetForm();
toast.success(lang === 'en' ? 'Measurement saved' : 'Messung gespeichert'); toast.success(lang === 'en' ? 'Measurement saved' : 'Messung gespeichert');
} else { } else {
@@ -536,12 +593,9 @@
<div class="history-item" class:editing={editingId === m._id}> <div class="history-item" class:editing={editingId === m._id}>
{#if editingId === m._id} {#if editingId === m._id}
<div class="edit-row"> <div class="edit-row">
<input <div class="edit-date-wrap">
type="date" <DatePicker bind:value={editDate} {lang} />
bind:value={editDate} </div>
class="edit-input edit-date"
onkeydown={(e) => onEditKey(e, m)}
/>
<div class="edit-num"> <div class="edit-num">
<input <input
type="number" type="number"
@@ -567,6 +621,11 @@
<span class="edit-unit">%</span> <span class="edit-unit">%</span>
</div> </div>
<div class="edit-actions"> <div class="edit-actions">
<a class="edit-more" href="/fitness/{measureSlug}/edit/{m._id}" aria-label={t('edit_measurement', lang)}>
<Pencil size={11} />
<span class="edit-more-label">{lang === 'en' ? 'Edit all fields' : 'Alle Felder bearbeiten'}</span>
<ChevronRight size={11} />
</a>
<button type="button" class="edit-btn cancel" onclick={cancelEdit} aria-label={t('cancel', lang)}> <button type="button" class="edit-btn cancel" onclick={cancelEdit} aria-label={t('cancel', lang)}>
<X size={14} /> <X size={14} />
</button> </button>
@@ -574,9 +633,6 @@
<Check size={14} /> <Check size={14} />
</button> </button>
</div> </div>
<a class="edit-more" href="/fitness/{measureSlug}/edit/{m._id}" aria-label={t('edit_measurement', lang)}>
{lang === 'en' ? 'Full edit →' : 'Alle Felder →'}
</a>
</div> </div>
{:else} {:else}
<div class="history-main"> <div class="history-main">
@@ -1283,9 +1339,9 @@
outline: none; outline: none;
border-color: var(--color-primary); border-color: var(--color-primary);
} }
.edit-input.edit-date { .edit-date-wrap {
flex: 1 1 120px; flex: 1 1 140px;
min-width: 110px; min-width: 130px;
} }
.edit-num { .edit-num {
display: inline-flex; display: inline-flex;
@@ -1338,13 +1394,29 @@
color: var(--color-text-primary); color: var(--color-text-primary);
} }
.edit-more { .edit-more {
flex-basis: 100%; display: inline-flex;
font-size: 0.68rem; align-items: center;
color: var(--color-text-tertiary); 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; 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 */ /* Desktop 2-col layout */
@media (min-width: 1024px) { @media (min-width: 1024px) {
@@ -6,6 +6,7 @@
import { cubicOut } from 'svelte/easing'; import { cubicOut } from 'svelte/easing';
import { detectFitnessLang, t } from '$lib/js/fitnessI18n'; import { detectFitnessLang, t } from '$lib/js/fitnessI18n';
import { toast } from '$lib/js/toast.svelte'; import { toast } from '$lib/js/toast.svelte';
import { confirm } from '$lib/js/confirmDialog.svelte';
import DatePicker from '$lib/components/DatePicker.svelte'; import DatePicker from '$lib/components/DatePicker.svelte';
import SaveFab from '$lib/components/SaveFab.svelte'; import SaveFab from '$lib/components/SaveFab.svelte';
import Toggle from '$lib/components/Toggle.svelte'; import Toggle from '$lib/components/Toggle.svelte';
@@ -188,11 +189,42 @@
} }
saving = true; saving = true;
try { 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', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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<string, string>} */
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) { if (res.ok) {
toast.success(lang === 'en' ? 'Measurement saved' : 'Messung gespeichert'); toast.success(lang === 'en' ? 'Measurement saved' : 'Messung gespeichert');
await goto(`/fitness/${measureSlug}`); await goto(`/fitness/${measureSlug}`);