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:
@@ -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 `<input type="checkbox">` 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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homepage",
|
||||
"version": "1.46.4",
|
||||
"version": "1.46.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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(),
|
||||
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 }
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
const doc = new BodyMeasurement({
|
||||
date: dayStart,
|
||||
weight,
|
||||
bodyFatPercent,
|
||||
caloricIntake,
|
||||
measurements,
|
||||
measurements: bp,
|
||||
notes,
|
||||
createdBy: user.nickname
|
||||
});
|
||||
await doc.save();
|
||||
return json({ measurement: doc, merged: false }, { status: 201 });
|
||||
}
|
||||
|
||||
await measurement.save();
|
||||
return json({ measurement }, { 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];
|
||||
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