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:
@@ -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' },
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
Reference in New Issue
Block a user