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
+6
View File
@@ -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' },
+81 -12
View File
@@ -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<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 = [];
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<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() {
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 @@
<div class="history-item" class:editing={editingId === m._id}>
{#if editingId === m._id}
<div class="edit-row">
<input
type="date"
bind:value={editDate}
class="edit-input edit-date"
onkeydown={(e) => onEditKey(e, m)}
/>
<div class="edit-date-wrap">
<DatePicker bind:value={editDate} {lang} />
</div>
<div class="edit-num">
<input
type="number"
@@ -567,6 +621,11 @@
<span class="edit-unit">%</span>
</div>
<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)}>
<X size={14} />
</button>
@@ -574,9 +633,6 @@
<Check size={14} />
</button>
</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>
{:else}
<div class="history-main">
@@ -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) {
@@ -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<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) {
toast.success(lang === 'en' ? 'Measurement saved' : 'Messung gespeichert');
await goto(`/fitness/${measureSlug}`);