diff --git a/bundle-budget.json b/bundle-budget.json
index 073abc99e..43ee910d7 100644
--- a/bundle-budget.json
+++ b/bundle-budget.json
@@ -1,5 +1,5 @@
{
- "$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.27 found /insights sitting a few hundred bytes under 445 on main and a few hundred over it on the release branch — the page had been at its ceiling for three releases and the release only had to breathe on it. The weight it was carrying was not its own: the authenticated shell imported one destination predicate from the `@/lib/record-settings` barrel, and the barrel also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode into the eager bundle of every authenticated route for a function that reads a URL slug. The shell imports the `classification` leaf now. Measured across all 585 client-rendered routes: none grew by a byte, 100 fell, by 4.7 KB at the median and 8.1 KB at the most; /insights 444 to 437, /measurements 442 to 435, / 456 to 450, /insights/mood 455 to 450. The whole-build sum moved the other way, 3451 to 3476, and that direction is the metric rather than the change: the aggregate counts each emitted chunk once, so a graph that leaves the chunk group every route shares gets counted again wherever it lands — here inside the deferred settings chunks that actually use it. Bytes on disk rose 25 KB; bytes a reader downloads only ever fell. The aggregate cap moves from 3452 to 3500, the same 24 KB headroom as the four re-measurements before it. Per-route ceilings keep their old values rather than tightening to the new floor: the CI runner measures each route about 1.5 KB heavier than a local arm64 build does, and a ceiling set from a local number would fail on a build that had not regressed. The static-catalog guard and the one-Recharts-chunk guard remain unchanged. Raised to 3700 at v1.37.32 for the seventh interface language (Korean): the lazy message catalogs went 6->7 chunks (1026 KB gz measured), pushing the aggregate to 3631 against the old 3500. The raise is entirely the new catalog; no route budget moved, and the four eager route ceilings are unchanged and still passing. Most of the aggregate is the dynamic locale-switch and fallback path that a first paint never loads (see issue #253), so this ceiling guards bytes almost no visitor requests; 3700 leaves ~69 KB for ordinary drift until that double-count is cleaned up. v1.38.14 re-measures the four eager routes and changes what the number means. The ceilings had been re-stated but never re-based since v1.37.4, so /insights and /measurements sat within a kilobyte of theirs and / and /insights/mood within one of theirs; e2e run 34043772954 failed on `/insights/page: 445 KB gz exceeds the 445 KB budget` with the other two at 459 of 460, and none of it was a regression anyone had made. A ceiling a route is already touching does not measure the route, it measures whichever dependency bump landed that week. The file states the measured value per route now, plus one drift allowance over it: measured on a local arm64 build at d48bbff01 as / 447, /insights 434, /measurements 433, /insights/mood 447, with a 20 KB allowance, so the effective ceilings are 467 / 454 / 453 / 467 — a raise for all four. The allowance covers the runner delta (the CI runner measures each route a little heavier than a local arm64 build) and ordinary week-to-week drift, while the step change the gate exists for — a statically imported message catalog, a duplicated chart library — is several times larger and still fails. Every run now prints the delta against the baseline, so a route that has crept 15 KB is legible in the log long before it is red; the flat ceiling could only ever say pass or fail. Re-base by re-measuring on a build with nothing else in it and writing the new numbers here with the reason, exactly as a raised ceiling was written before. Raised to 3724 at v1.39.4 on a measured delta, not a feature. The release branch alone measured 3699 KB gz (3698.9) against the 3700 cap, so the ordinary drift room the v1.37.32 raise left had been used up by the releases since; the review fixes on top of it added 1.3 KB gz across the whole build (3700.2), almost all of it new copy in the seven lazy locale catalogs (the total and reused-table labels, the step and chip wording) plus the result-panel states. The four eager routes measured the same on both builds (/ 458, /insights/mood 456, /insights 445, /measurements 442), all inside their ceilings. The aggregate cap moves from 3700 to 3724, the same 24 KB headroom as the re-measurements before it.",
+ "$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.27 found /insights sitting a few hundred bytes under 445 on main and a few hundred over it on the release branch — the page had been at its ceiling for three releases and the release only had to breathe on it. The weight it was carrying was not its own: the authenticated shell imported one destination predicate from the `@/lib/record-settings` barrel, and the barrel also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode into the eager bundle of every authenticated route for a function that reads a URL slug. The shell imports the `classification` leaf now. Measured across all 585 client-rendered routes: none grew by a byte, 100 fell, by 4.7 KB at the median and 8.1 KB at the most; /insights 444 to 437, /measurements 442 to 435, / 456 to 450, /insights/mood 455 to 450. The whole-build sum moved the other way, 3451 to 3476, and that direction is the metric rather than the change: the aggregate counts each emitted chunk once, so a graph that leaves the chunk group every route shares gets counted again wherever it lands — here inside the deferred settings chunks that actually use it. Bytes on disk rose 25 KB; bytes a reader downloads only ever fell. The aggregate cap moves from 3452 to 3500, the same 24 KB headroom as the four re-measurements before it. Per-route ceilings keep their old values rather than tightening to the new floor: the CI runner measures each route about 1.5 KB heavier than a local arm64 build does, and a ceiling set from a local number would fail on a build that had not regressed. The static-catalog guard and the one-Recharts-chunk guard remain unchanged. Raised to 3700 at v1.37.32 for the seventh interface language (Korean): the lazy message catalogs went 6->7 chunks (1026 KB gz measured), pushing the aggregate to 3631 against the old 3500. The raise is entirely the new catalog; no route budget moved, and the four eager route ceilings are unchanged and still passing. Most of the aggregate is the dynamic locale-switch and fallback path that a first paint never loads (see issue #253), so this ceiling guards bytes almost no visitor requests; 3700 leaves ~69 KB for ordinary drift until that double-count is cleaned up. v1.38.14 re-measures the four eager routes and changes what the number means. The ceilings had been re-stated but never re-based since v1.37.4, so /insights and /measurements sat within a kilobyte of theirs and / and /insights/mood within one of theirs; e2e run 34043772954 failed on `/insights/page: 445 KB gz exceeds the 445 KB budget` with the other two at 459 of 460, and none of it was a regression anyone had made. A ceiling a route is already touching does not measure the route, it measures whichever dependency bump landed that week. The file states the measured value per route now, plus one drift allowance over it: measured on a local arm64 build at d48bbff01 as / 447, /insights 434, /measurements 433, /insights/mood 447, with a 20 KB allowance, so the effective ceilings are 467 / 454 / 453 / 467 — a raise for all four. The allowance covers the runner delta (the CI runner measures each route a little heavier than a local arm64 build) and ordinary week-to-week drift, while the step change the gate exists for — a statically imported message catalog, a duplicated chart library — is several times larger and still fails. Every run now prints the delta against the baseline, so a route that has crept 15 KB is legible in the log long before it is red; the flat ceiling could only ever say pass or fail. Re-base by re-measuring on a build with nothing else in it and writing the new numbers here with the reason, exactly as a raised ceiling was written before. Raised to 3724 at v1.39.4 on a measured delta, not a feature. The release branch alone measured 3699 KB gz (3698.9) against the 3700 cap, so the ordinary drift room the v1.37.32 raise left had been used up by the releases since; the review fixes on top of it added 1.3 KB gz across the whole build (3700.2), almost all of it new copy in the seven lazy locale catalogs (the total and reused-table labels, the step and chip wording) plus the result-panel states. The four eager routes measured the same on both builds (/ 458, /insights/mood 456, /insights 445, /measurements 442), all inside their ceilings. The aggregate cap moves from 3700 to 3724, the same 24 KB headroom as the re-measurements before it. Raised to 3749 at v1.39.9 on a measured delta. Main measured 3718 KB gz after v1.39.7 and v1.39.8 (unit-aware copy, the phone layouts, the time-zone work and the account-settings restore labels, almost all of it new strings in the seven lazy locale catalogs), leaving 6 KB of the v1.39.4 headroom; the manual workout entry (#1085: the form, its sheet, the delete action and their copy in seven locales) adds about 7 KB and measured 3725. The four eager routes stay inside their drift allowances (/ +12.5, /insights +12.1, /insights/mood +10.2 KB of 20 on main). The aggregate cap moves from 3724 to 3749, the same 24 KB headroom as the re-measurements before it.",
"routeBaselineKbGz": {
"/page": 447,
"/insights/page": 434,
@@ -7,6 +7,6 @@
"/insights/mood/page": 447
},
"routeDriftAllowanceKbGz": 20,
- "totalClientKbGz": 3724,
+ "totalClientKbGz": 3749,
"maxRechartsChunks": 1
}
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 6539b387b..366ea747c 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -4661,6 +4661,42 @@ paths:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a4
"429": *a5
+ delete:
+ tags:
+ - Measurements
+ summary: Delete a workout entered by hand
+ description: "Removes a `MANUAL` workout outright, with its child rows, and any personal record that workout set; a
+ silent detection pass then re-derives the previous best. A workout from any other source answers 409
+ (`workout.delete.synced_source`), because the next sync would write it back. Own record only: a cookie session
+ or a wildcard token. Another user's row answers 404."
+ parameters:
+ - in: path
+ name: id
+ schema:
+ type: string
+ required: true
+ responses:
+ "200":
+ description: Deleted.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/DeleteWorkoutResponse"
+ "401": *a1
+ "404":
+ description: Workout not found (or owned by another user).
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorEnvelope"
+ "409":
+ description: The workout was synced from a device or service, not entered by hand.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorEnvelope"
+ "422": *a4
+ "429": *a5
/api/workouts/batch:
post:
tags:
@@ -31764,12 +31800,13 @@ components:
vaccination.encounter-not-found, vaccination.identity-required, vaccination.invalid,
vaccination.link.invalid, vaccination.not-found, vaccination.practitioner-not-found; workout —
workout.batch.invalid, workout.batch.payload_too_large, workout.batch.source_not_permitted,
- workout.batch.too_large; (unprefixed) — about_me_conflict, ai_response_truncated, coach_prefs_conflict,
- dashboard_layout_conflict, empty_file, health_score_config_conflict, insights_layout_conflict,
- invalid_base_updated_at, json_carries_no_intake_time, json_not_an_array, medication_layout_conflict,
- missing_required_columns, modules_conflict, mood_tag_layout_conflict, not_configured,
- notification_prefs_conflict, oidc_only, private_origin_not_approved, private_origin_not_grantable,
- rate_limited_self, too_many_rows, unreadable_json, upstream_invalid_json, vapid_not_configured"
+ workout.batch.too_large, workout.delete.synced_source; (unprefixed) — about_me_conflict,
+ ai_response_truncated, coach_prefs_conflict, dashboard_layout_conflict, empty_file,
+ health_score_config_conflict, insights_layout_conflict, invalid_base_updated_at,
+ json_carries_no_intake_time, json_not_an_array, medication_layout_conflict, missing_required_columns,
+ modules_conflict, mood_tag_layout_conflict, not_configured, notification_prefs_conflict, oidc_only,
+ private_origin_not_approved, private_origin_not_grantable, rate_limited_self, too_many_rows,
+ unreadable_json, upstream_invalid_json, vapid_not_configured"
type: string
capability:
description: "On an AI refusal: the capability that is unavailable, one of the keys of `AiCapabilities` (`coach`,
@@ -43053,6 +43090,30 @@ components:
description: "One AI capability, resolved for one record from every layer that can say no. Render from it; never
recompute it. Data never depends on it: measurements, scores, statistics and device records load whatever it
says."
+ DeleteWorkoutResponse:
+ type: object
+ properties:
+ data:
+ type: object
+ properties:
+ deleted:
+ type: boolean
+ const: true
+ required:
+ - deleted
+ additionalProperties: false
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
BatchWorkoutsResponse:
type: object
properties:
diff --git a/messages/de.json b/messages/de.json
index eafb5e548..50d14c443 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -403,7 +403,9 @@
"medication": "Medikament",
"medicationDescription": "Eine Dosis als genommen markieren",
"mood": "Stimmung",
- "moodDescription": "Stimmung und Tags erfassen"
+ "moodDescription": "Stimmung und Tags erfassen",
+ "workout": "Workout",
+ "workoutDescription": "Eine Einheit, die kein Gerät aufgezeichnet hat"
},
"cycle": "Zyklus",
"labs": "Laborwerte",
@@ -520,6 +522,7 @@
"quickAddMeasurement": "Messung erfassen",
"quickAddMood": "Stimmung erfassen",
"quickAddMedicationIntake": "Einnahme erfassen",
+ "quickAddWorkout": "Workout erfassen",
"customizeDashboard": "Dashboard anpassen",
"medicationIntakeQuickAdd": {
"sheetTitle": "Medikamenteneinnahme erfassen",
@@ -3359,7 +3362,7 @@
"description": "Letzte Läufe, Touren, Spaziergänge und Krafteinheiten — quellenübergreifend entdoppelt zwischen Apple Watch und Withings.",
"emptyState": {
"title": "Noch keine Workouts",
- "description": "Öffne die Apple-Health-App auf dem iPhone, damit Workouts hier synchronisiert werden. Withings-Aktivitätsimport pflegt Geh- und Radtouren mit ein."
+ "description": "Workouts kommen per Apple Health vom iPhone und von Withings. Du kannst eins auch von Hand erfassen."
},
"detail": {
"backToList": "Zurück zu Workouts",
@@ -3437,7 +3440,35 @@
"countFew": "{count} Workouts",
"filterLabel": "Nach Sportart filtern",
"filterAll": "Alle Sportarten",
- "filterEmpty": "Keine Einheiten dieser Sportart in deinem Verlauf."
+ "filterEmpty": "Keine Einheiten dieser Sportart in deinem Verlauf.",
+ "manual": {
+ "logWorkout": "Workout erfassen",
+ "sheetTitle": "Workout erfassen",
+ "sheetDescription": "Trag eine Einheit von Hand ein: was, wann und wie lange.",
+ "sportLabel": "Aktivität",
+ "sportPlaceholder": "Aktivität wählen",
+ "startLabel": "Beginn",
+ "durationLabel": "Dauer",
+ "distanceLabel": "Distanz ({unit})",
+ "energyLabel": "Aktive Energie (kcal)",
+ "alreadySaved": "Dieses Workout ist schon gespeichert",
+ "saveError": "Das Workout konnte nicht gespeichert werden",
+ "deleteLabel": "Workout löschen",
+ "deleteConfirmTitle": "Dieses Workout löschen?",
+ "deleteConfirmDescription": "Dieses von Hand eingetragene Workout wird endgültig entfernt.",
+ "deleted": "Workout gelöscht",
+ "deleteError": "Das Workout konnte nicht gelöscht werden",
+ "errors": {
+ "sportRequired": "Wähle eine Aktivität.",
+ "startRequired": "Gib Datum und Uhrzeit ein.",
+ "startInFuture": "Der Beginn darf nicht in der Zukunft liegen.",
+ "durationRequired": "Gib an, wie lange das Workout gedauert hat.",
+ "durationInvalid": "Gib ganze Stunden und Minuten von 0 bis 59 ein.",
+ "durationTooLong": "Ein Workout dauert höchstens 24 Stunden.",
+ "distanceInvalid": "Gib die Distanz als Zahl in einem realistischen Bereich ein.",
+ "energyInvalid": "Gib die aktive Energie als Zahl bis 20.000 kcal ein."
+ }
+ }
},
"bloodGlucose": {
"title": "Blutzucker",
diff --git a/messages/en.json b/messages/en.json
index ececfe100..b23ee1634 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -403,7 +403,9 @@
"medication": "Medication",
"medicationDescription": "Mark a dose as taken",
"mood": "Mood",
- "moodDescription": "Log your mood and tags"
+ "moodDescription": "Log your mood and tags",
+ "workout": "Workout",
+ "workoutDescription": "A session your devices did not record"
},
"cycle": "Cycle",
"labs": "Labs",
@@ -520,6 +522,7 @@
"quickAddMeasurement": "Log measurement",
"quickAddMood": "Log mood",
"quickAddMedicationIntake": "Log medication intake",
+ "quickAddWorkout": "Log workout",
"customizeDashboard": "Customize dashboard",
"medicationIntakeQuickAdd": {
"sheetTitle": "Log medication intake",
@@ -3359,7 +3362,7 @@
"description": "Recent runs, rides, walks and strength sessions, deduped across Apple Watch and Withings sources.",
"emptyState": {
"title": "No workouts yet",
- "description": "Open the Apple Health app on your iPhone to sync workouts here. Withings activity import keeps walking and cycling sessions flowing in too."
+ "description": "Workouts sync from Apple Health on your iPhone and from Withings. You can also log one by hand."
},
"detail": {
"backToList": "Back to workouts",
@@ -3437,7 +3440,35 @@
"countFew": "{count} workouts",
"filterLabel": "Filter by sport",
"filterAll": "All sports",
- "filterEmpty": "No sessions of this sport in your history."
+ "filterEmpty": "No sessions of this sport in your history.",
+ "manual": {
+ "logWorkout": "Log workout",
+ "sheetTitle": "Log workout",
+ "sheetDescription": "Enter a session by hand: what you did, when, and for how long.",
+ "sportLabel": "Activity",
+ "sportPlaceholder": "Choose an activity",
+ "startLabel": "Start",
+ "durationLabel": "Duration",
+ "distanceLabel": "Distance ({unit})",
+ "energyLabel": "Active energy (kcal)",
+ "alreadySaved": "This workout is already saved",
+ "saveError": "The workout could not be saved",
+ "deleteLabel": "Delete workout",
+ "deleteConfirmTitle": "Delete this workout?",
+ "deleteConfirmDescription": "This workout you entered by hand will be removed for good.",
+ "deleted": "Workout deleted",
+ "deleteError": "The workout could not be deleted",
+ "errors": {
+ "sportRequired": "Choose an activity.",
+ "startRequired": "Enter a date and time.",
+ "startInFuture": "The start cannot be in the future.",
+ "durationRequired": "Enter how long the workout lasted.",
+ "durationInvalid": "Enter whole hours, and minutes from 0 to 59.",
+ "durationTooLong": "A workout can last at most 24 hours.",
+ "distanceInvalid": "Enter the distance as a number within a realistic range.",
+ "energyInvalid": "Enter active energy as a number up to 20,000 kcal."
+ }
+ }
},
"bloodGlucose": {
"title": "Blood Glucose",
diff --git a/messages/es.json b/messages/es.json
index 1d70d0463..cbde8a5a7 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -403,7 +403,9 @@
"medication": "Medicamento",
"medicationDescription": "Marcar una dosis como tomada",
"mood": "Estado de ánimo",
- "moodDescription": "Registra tu estado de ánimo y etiquetas"
+ "moodDescription": "Registra tu estado de ánimo y etiquetas",
+ "workout": "Entrenamiento",
+ "workoutDescription": "Una sesión que tus dispositivos no registraron"
},
"cycle": "Ciclo",
"labs": "Análisis",
@@ -520,6 +522,7 @@
"quickAddMeasurement": "Registrar medición",
"quickAddMood": "Registrar estado de ánimo",
"quickAddMedicationIntake": "Registrar toma",
+ "quickAddWorkout": "Registrar entrenamiento",
"customizeDashboard": "Personalizar panel",
"medicationIntakeQuickAdd": {
"sheetTitle": "Registrar toma de medicación",
@@ -3359,7 +3362,7 @@
"description": "Carreras, salidas en bici, paseos y sesiones de fuerza recientes, deduplicados entre Apple Watch y Withings.",
"emptyState": {
"title": "Aún no hay entrenamientos",
- "description": "Abre la app Apple Salud en el iPhone para sincronizar tus entrenamientos. La importación de actividad de Withings también incorpora caminatas y rutas en bici."
+ "description": "Los entrenamientos llegan desde Apple Salud en el iPhone y desde Withings. También puedes registrar uno a mano."
},
"detail": {
"backToList": "Volver a entrenamientos",
@@ -3437,7 +3440,35 @@
"countFew": "{count} entrenamientos",
"filterLabel": "Filtrar por deporte",
"filterAll": "Todos los deportes",
- "filterEmpty": "No hay sesiones de este deporte en tu historial."
+ "filterEmpty": "No hay sesiones de este deporte en tu historial.",
+ "manual": {
+ "logWorkout": "Registrar entrenamiento",
+ "sheetTitle": "Registrar entrenamiento",
+ "sheetDescription": "Introduce una sesión a mano: qué hiciste, cuándo y durante cuánto tiempo.",
+ "sportLabel": "Actividad",
+ "sportPlaceholder": "Elige una actividad",
+ "startLabel": "Inicio",
+ "durationLabel": "Duración",
+ "distanceLabel": "Distancia ({unit})",
+ "energyLabel": "Energía activa (kcal)",
+ "alreadySaved": "Este entrenamiento ya está guardado",
+ "saveError": "No se pudo guardar el entrenamiento",
+ "deleteLabel": "Eliminar entrenamiento",
+ "deleteConfirmTitle": "¿Eliminar este entrenamiento?",
+ "deleteConfirmDescription": "Este entrenamiento que introdujiste a mano se eliminará de forma definitiva.",
+ "deleted": "Entrenamiento eliminado",
+ "deleteError": "No se pudo eliminar el entrenamiento",
+ "errors": {
+ "sportRequired": "Elige una actividad.",
+ "startRequired": "Introduce una fecha y una hora.",
+ "startInFuture": "El inicio no puede estar en el futuro.",
+ "durationRequired": "Indica cuánto duró el entrenamiento.",
+ "durationInvalid": "Introduce horas enteras y minutos de 0 a 59.",
+ "durationTooLong": "Un entrenamiento puede durar como máximo 24 horas.",
+ "distanceInvalid": "Introduce la distancia como un número dentro de un rango realista.",
+ "energyInvalid": "Introduce la energía activa como un número de hasta 20.000 kcal."
+ }
+ }
},
"bloodGlucose": {
"title": "Glucosa en sangre",
diff --git a/messages/fr.json b/messages/fr.json
index 5b87d28fc..4aaac4602 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -403,7 +403,9 @@
"medication": "Médicament",
"medicationDescription": "Marquer une dose comme prise",
"mood": "Humeur",
- "moodDescription": "Enregistrez votre humeur et des étiquettes"
+ "moodDescription": "Enregistrez votre humeur et des étiquettes",
+ "workout": "Entraînement",
+ "workoutDescription": "Une séance que vos appareils n’ont pas enregistrée"
},
"cycle": "Cycle",
"labs": "Analyses",
@@ -520,6 +522,7 @@
"quickAddMeasurement": "Saisir une mesure",
"quickAddMood": "Saisir une humeur",
"quickAddMedicationIntake": "Saisir une prise",
+ "quickAddWorkout": "Saisir un entraînement",
"customizeDashboard": "Personnaliser le tableau de bord",
"medicationIntakeQuickAdd": {
"sheetTitle": "Saisir une prise de médicament",
@@ -3359,7 +3362,7 @@
"description": "Courses, sorties à vélo, marches et séances de musculation récentes, dédupliquées entre Apple Watch et Withings.",
"emptyState": {
"title": "Aucun entraînement",
- "description": "Ouvre l'app Apple Santé sur ton iPhone pour synchroniser tes entraînements. L'import d'activité Withings alimente également la marche et le vélo."
+ "description": "Tes entraînements arrivent d'Apple Santé sur l'iPhone et de Withings. Tu peux aussi en saisir un à la main."
},
"detail": {
"backToList": "Retour aux entraînements",
@@ -3437,7 +3440,35 @@
"countFew": "{count} séances",
"filterLabel": "Filtrer par sport",
"filterAll": "Tous les sports",
- "filterEmpty": "Aucune séance de ce sport dans votre historique."
+ "filterEmpty": "Aucune séance de ce sport dans votre historique.",
+ "manual": {
+ "logWorkout": "Saisir un entraînement",
+ "sheetTitle": "Saisir un entraînement",
+ "sheetDescription": "Saisissez une séance à la main : ce que vous avez fait, quand et combien de temps.",
+ "sportLabel": "Activité",
+ "sportPlaceholder": "Choisir une activité",
+ "startLabel": "Début",
+ "durationLabel": "Durée",
+ "distanceLabel": "Distance ({unit})",
+ "energyLabel": "Énergie active (kcal)",
+ "alreadySaved": "Cet entraînement est déjà enregistré",
+ "saveError": "L’entraînement n’a pas pu être enregistré",
+ "deleteLabel": "Supprimer l’entraînement",
+ "deleteConfirmTitle": "Supprimer cet entraînement ?",
+ "deleteConfirmDescription": "Cet entraînement saisi à la main sera supprimé définitivement.",
+ "deleted": "Entraînement supprimé",
+ "deleteError": "L’entraînement n’a pas pu être supprimé",
+ "errors": {
+ "sportRequired": "Choisissez une activité.",
+ "startRequired": "Saisissez une date et une heure.",
+ "startInFuture": "Le début ne peut pas être dans le futur.",
+ "durationRequired": "Indiquez la durée de l’entraînement.",
+ "durationInvalid": "Saisissez des heures entières et des minutes de 0 à 59.",
+ "durationTooLong": "Un entraînement dure au maximum 24 heures.",
+ "distanceInvalid": "Saisissez la distance sous forme de nombre, dans une plage réaliste.",
+ "energyInvalid": "Saisissez l’énergie active sous forme de nombre, jusqu’à 20 000 kcal."
+ }
+ }
},
"bloodGlucose": {
"title": "Glycémie",
diff --git a/messages/it.json b/messages/it.json
index 035aec921..f35614d3a 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -403,7 +403,9 @@
"medication": "Farmaco",
"medicationDescription": "Segna una dose come assunta",
"mood": "Umore",
- "moodDescription": "Registra umore e tag"
+ "moodDescription": "Registra umore e tag",
+ "workout": "Allenamento",
+ "workoutDescription": "Una sessione che i tuoi dispositivi non hanno registrato"
},
"cycle": "Ciclo",
"labs": "Esami",
@@ -520,6 +522,7 @@
"quickAddMeasurement": "Registra misurazione",
"quickAddMood": "Registra umore",
"quickAddMedicationIntake": "Registra assunzione",
+ "quickAddWorkout": "Registra allenamento",
"customizeDashboard": "Personalizza dashboard",
"medicationIntakeQuickAdd": {
"sheetTitle": "Registra assunzione del farmaco",
@@ -3359,7 +3362,7 @@
"description": "Corse, uscite in bici, camminate e sessioni di forza recenti, deduplicati tra Apple Watch e Withings.",
"emptyState": {
"title": "Nessun allenamento",
- "description": "Apri l'app Salute di Apple sull'iPhone per sincronizzare gli allenamenti. L'import di attività di Withings porta dentro anche camminate e uscite in bici."
+ "description": "Gli allenamenti arrivano da Salute di Apple sull'iPhone e da Withings. Puoi anche registrarne uno a mano."
},
"detail": {
"backToList": "Torna agli allenamenti",
@@ -3437,7 +3440,35 @@
"countFew": "{count} allenamenti",
"filterLabel": "Filtra per sport",
"filterAll": "Tutti gli sport",
- "filterEmpty": "Nessuna sessione di questo sport nella tua cronologia."
+ "filterEmpty": "Nessuna sessione di questo sport nella tua cronologia.",
+ "manual": {
+ "logWorkout": "Registra allenamento",
+ "sheetTitle": "Registra allenamento",
+ "sheetDescription": "Inserisci una sessione a mano: cosa hai fatto, quando e per quanto tempo.",
+ "sportLabel": "Attività",
+ "sportPlaceholder": "Scegli un’attività",
+ "startLabel": "Inizio",
+ "durationLabel": "Durata",
+ "distanceLabel": "Distanza ({unit})",
+ "energyLabel": "Energia attiva (kcal)",
+ "alreadySaved": "Questo allenamento è già salvato",
+ "saveError": "Non è stato possibile salvare l’allenamento",
+ "deleteLabel": "Elimina allenamento",
+ "deleteConfirmTitle": "Eliminare questo allenamento?",
+ "deleteConfirmDescription": "Questo allenamento inserito a mano verrà rimosso definitivamente.",
+ "deleted": "Allenamento eliminato",
+ "deleteError": "Non è stato possibile eliminare l’allenamento",
+ "errors": {
+ "sportRequired": "Scegli un’attività.",
+ "startRequired": "Inserisci data e ora.",
+ "startInFuture": "L’inizio non può essere nel futuro.",
+ "durationRequired": "Indica quanto è durato l’allenamento.",
+ "durationInvalid": "Inserisci ore intere e minuti da 0 a 59.",
+ "durationTooLong": "Un allenamento può durare al massimo 24 ore.",
+ "distanceInvalid": "Inserisci la distanza come numero in un intervallo realistico.",
+ "energyInvalid": "Inserisci l’energia attiva come numero fino a 20.000 kcal."
+ }
+ }
},
"bloodGlucose": {
"title": "Glicemia",
diff --git a/messages/ko.json b/messages/ko.json
index 54c70b0e6..1f82dceb1 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -403,7 +403,9 @@
"medication": "약",
"medicationDescription": "복용으로 표시하기",
"mood": "기분",
- "moodDescription": "기분과 태그 기록하기"
+ "moodDescription": "기분과 태그 기록하기",
+ "workout": "운동",
+ "workoutDescription": "기기가 기록하지 못한 운동"
},
"cycle": "생리 주기",
"labs": "검사",
@@ -520,6 +522,7 @@
"quickAddMeasurement": "측정 기록",
"quickAddMood": "기분 기록",
"quickAddMedicationIntake": "복용 기록",
+ "quickAddWorkout": "운동 기록",
"customizeDashboard": "대시보드 맞춤 설정",
"medicationIntakeQuickAdd": {
"sheetTitle": "복용 기록",
@@ -3359,7 +3362,7 @@
"description": "최근 달리기, 자전거, 걷기, 근력 운동 세션이에요. Apple Watch와 Withings 소스 사이 중복은 정리했어요.",
"emptyState": {
"title": "아직 운동 기록이 없어요",
- "description": "iPhone에서 Apple Health 앱을 열면 운동이 여기로 동기화돼요. Withings 활동 불러오기로 걷기와 자전거 세션도 계속 들어와요."
+ "description": "운동은 iPhone의 Apple Health와 Withings에서 동기화돼요. 직접 기록할 수도 있어요."
},
"detail": {
"backToList": "운동으로 돌아가기",
@@ -3437,7 +3440,35 @@
"countFew": "운동 {count}개",
"filterLabel": "종목별 필터",
"filterAll": "전체 종목",
- "filterEmpty": "기록에 이 종목의 세션이 없어요."
+ "filterEmpty": "기록에 이 종목의 세션이 없어요.",
+ "manual": {
+ "logWorkout": "운동 기록",
+ "sheetTitle": "운동 기록",
+ "sheetDescription": "무엇을, 언제, 얼마나 했는지 직접 입력하세요.",
+ "sportLabel": "활동",
+ "sportPlaceholder": "활동 선택",
+ "startLabel": "시작",
+ "durationLabel": "시간",
+ "distanceLabel": "거리({unit})",
+ "energyLabel": "활동 에너지(kcal)",
+ "alreadySaved": "이미 저장된 운동입니다",
+ "saveError": "운동을 저장하지 못했습니다",
+ "deleteLabel": "운동 삭제",
+ "deleteConfirmTitle": "이 운동을 삭제할까요?",
+ "deleteConfirmDescription": "직접 입력한 이 운동이 영구적으로 삭제됩니다.",
+ "deleted": "운동을 삭제했습니다",
+ "deleteError": "운동을 삭제하지 못했습니다",
+ "errors": {
+ "sportRequired": "활동을 선택하세요.",
+ "startRequired": "날짜와 시간을 입력하세요.",
+ "startInFuture": "시작 시간은 미래일 수 없습니다.",
+ "durationRequired": "운동한 시간을 입력하세요.",
+ "durationInvalid": "시간은 정수로, 분은 0에서 59 사이로 입력하세요.",
+ "durationTooLong": "운동은 최대 24시간까지 입력할 수 있습니다.",
+ "distanceInvalid": "거리를 현실적인 범위의 숫자로 입력하세요.",
+ "energyInvalid": "활동 에너지는 20,000kcal 이하의 숫자로 입력하세요."
+ }
+ }
},
"bloodGlucose": {
"title": "혈당",
diff --git a/messages/pl.json b/messages/pl.json
index 6ff598d6c..2c185809c 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -403,7 +403,9 @@
"medication": "Lek",
"medicationDescription": "Oznacz dawkę jako przyjętą",
"mood": "Nastrój",
- "moodDescription": "Zapisz nastrój i tagi"
+ "moodDescription": "Zapisz nastrój i tagi",
+ "workout": "Trening",
+ "workoutDescription": "Sesja, której nie zapisały twoje urządzenia"
},
"cycle": "Cykl",
"labs": "Wyniki badań",
@@ -520,6 +522,7 @@
"quickAddMeasurement": "Zarejestruj pomiar",
"quickAddMood": "Zarejestruj nastrój",
"quickAddMedicationIntake": "Zarejestruj przyjęcie",
+ "quickAddWorkout": "Zarejestruj trening",
"customizeDashboard": "Dostosuj pulpit",
"medicationIntakeQuickAdd": {
"sheetTitle": "Zarejestruj przyjęcie leku",
@@ -3359,7 +3362,7 @@
"description": "Ostatnie biegi, jazdy, marsze i treningi siłowe — zdeduplikowane między Apple Watch a Withings.",
"emptyState": {
"title": "Brak treningów",
- "description": "Otwórz aplikację Apple Zdrowie na iPhonie, aby zsynchronizować treningi. Import aktywności z Withings także dorzuca marsze i przejażdżki."
+ "description": "Treningi trafiają tu z Apple Zdrowie na iPhonie i z Withings. Możesz też wpisać trening ręcznie."
},
"detail": {
"backToList": "Powrót do treningów",
@@ -3437,7 +3440,35 @@
"countFew": "{count} treningi",
"filterLabel": "Filtruj według sportu",
"filterAll": "Wszystkie sporty",
- "filterEmpty": "Brak treningów tego sportu w Twojej historii."
+ "filterEmpty": "Brak treningów tego sportu w Twojej historii.",
+ "manual": {
+ "logWorkout": "Zarejestruj trening",
+ "sheetTitle": "Zarejestruj trening",
+ "sheetDescription": "Wpisz sesję ręcznie: rodzaj aktywności, początek i czas trwania.",
+ "sportLabel": "Aktywność",
+ "sportPlaceholder": "Wybierz aktywność",
+ "startLabel": "Początek",
+ "durationLabel": "Czas trwania",
+ "distanceLabel": "Dystans ({unit})",
+ "energyLabel": "Energia czynna (kcal)",
+ "alreadySaved": "Ten trening jest już zapisany",
+ "saveError": "Nie udało się zapisać treningu",
+ "deleteLabel": "Usuń trening",
+ "deleteConfirmTitle": "Usunąć ten trening?",
+ "deleteConfirmDescription": "Ten ręcznie wpisany trening zostanie trwale usunięty.",
+ "deleted": "Trening usunięty",
+ "deleteError": "Nie udało się usunąć treningu",
+ "errors": {
+ "sportRequired": "Wybierz aktywność.",
+ "startRequired": "Podaj datę i godzinę.",
+ "startInFuture": "Początek nie może być w przyszłości.",
+ "durationRequired": "Podaj, jak długo trwał trening.",
+ "durationInvalid": "Podaj pełne godziny i minuty od 0 do 59.",
+ "durationTooLong": "Trening może trwać najwyżej 24 godziny.",
+ "distanceInvalid": "Podaj dystans jako liczbę w realistycznym zakresie.",
+ "energyInvalid": "Podaj energię czynną jako liczbę do 20 000 kcal."
+ }
+ }
},
"bloodGlucose": {
"title": "Poziom glukozy",
diff --git a/src/__tests__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts
index fcf209cfe..49b4bf75f 100644
--- a/src/__tests__/destructive-control-guard.test.ts
+++ b/src/__tests__/destructive-control-guard.test.ts
@@ -380,6 +380,13 @@ const REGISTRY: DestructiveEntry[] = [
recovery: "tombstoned-no-restore",
confirm: ["AlertDialog"],
},
+ {
+ file: "components/workouts/delete-workout-button.tsx",
+ destroys:
+ "one workout entered by hand, with the personal records it set; synced workouts are refused by the route",
+ recovery: "permanent",
+ confirm: ["AlertDialog"],
+ },
// ── Visits and the address book ─────────────────────────────────────────
{
diff --git a/src/app/api/workouts/[id]/route.ts b/src/app/api/workouts/[id]/route.ts
index f7a7350b9..21f04d8a5 100644
--- a/src/app/api/workouts/[id]/route.ts
+++ b/src/app/api/workouts/[id]/route.ts
@@ -21,13 +21,21 @@
* route geometry); absent for manual entries.
* - Ownership-gated: a row owned by another user resolves as 404 so
* the existence channel never leaks.
+ *
+ * `DELETE /api/workouts/{id}` removes a workout entered by hand. Only a
+ * `MANUAL` row: a synced workout would come back with the next sync, so
+ * deleting it here would be a promise the record cannot keep. See the
+ * handler below.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
-import { apiError, apiSuccess } from "@/lib/api-response";
+import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
+import { auditLog } from "@/lib/auth/audit";
+import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
+import { enqueuePrDetection } from "@/lib/jobs/pr-detection";
import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
@@ -357,3 +365,103 @@ export const GET = apiHandler(
});
},
);
+
+/**
+ * `DELETE /api/workouts/{id}` — remove a workout that was entered by hand.
+ *
+ * - Own record only: `requireAuth()` with no scope admits a cookie session or
+ * a wildcard token and refuses an acting-account carrier, the same arm the
+ * detail read above uses. A narrow token is refused.
+ * - Ownership: another user's row answers 404, like the read.
+ * - `MANUAL` only. Every other source is a sync, and the next sync would
+ * write the row back, so it answers 409 instead of pretending.
+ * - Not module-gated: deleting is cleanup of the person's own rows, which
+ * the data layer allows whatever the module says
+ * (`module-route-gate-inventory.test.ts`).
+ *
+ * The workout table carries no tombstone, so the row is removed outright;
+ * route, samples, insight and claim rows cascade with it. A personal record
+ * the workout set would otherwise outlive it (the detector only ever raises
+ * a stored best), so the records keyed on this workout go in the same
+ * transaction and a silent detection pass re-derives the honest best.
+ */
+export const DELETE = apiHandler(
+ async (request: NextRequest, { params }: RouteParams) => {
+ const { user } = await requireAuth();
+ const { id } = await params;
+
+ const row = await prisma.workout.findUnique({
+ where: { id },
+ select: {
+ id: true,
+ userId: true,
+ source: true,
+ externalId: true,
+ startedAt: true,
+ sportType: true,
+ },
+ });
+ if (!row || row.userId !== user.id) {
+ return apiError("Workout not found", 404);
+ }
+ if (row.source !== "MANUAL") {
+ annotate({
+ action: {
+ name: "workout.delete",
+ entity_type: "workout",
+ entity_id: id,
+ },
+ meta: { outcome: "synced_source", source: row.source },
+ });
+ return apiError(
+ "Only a workout entered by hand can be deleted here",
+ 409,
+ { errorCode: "workout.delete.synced_source" },
+ );
+ }
+
+ const removedRecords = await prisma.$transaction(async (tx) => {
+ const records = await tx.personalRecord.deleteMany({
+ where: {
+ userId: user.id,
+ metricSlot: { not: null },
+ source: "MANUAL",
+ ...(row.externalId !== null
+ ? { externalId: row.externalId }
+ : { externalId: null, achievedAt: row.startedAt }),
+ },
+ });
+ await tx.workout.delete({ where: { id: row.id } });
+ return records.count;
+ });
+
+ await auditLog("workout.delete", {
+ userId: user.id,
+ ipAddress: getClientIp(request),
+ details: {
+ workoutId: id,
+ sportType: row.sportType,
+ personalRecordsRemoved: removedRecords,
+ },
+ });
+ annotate({
+ action: { name: "workout.delete", entity_type: "workout", entity_id: id },
+ meta: { outcome: "deleted", personalRecordsRemoved: removedRecords },
+ });
+
+ invalidateUserMeasurements(user.id, { evict: true });
+
+ if (removedRecords > 0) {
+ try {
+ await enqueuePrDetection(user.id, { silent: true });
+ } catch (err) {
+ annotate({
+ action: { name: "personal_records.detection_enqueue_failed" },
+ meta: { error: err instanceof Error ? err.message : String(err) },
+ });
+ }
+ }
+
+ return apiSuccess({ deleted: true });
+ },
+);
diff --git a/src/app/api/workouts/__tests__/delete.test.ts b/src/app/api/workouts/__tests__/delete.test.ts
new file mode 100644
index 000000000..340fd2cee
--- /dev/null
+++ b/src/app/api/workouts/__tests__/delete.test.ts
@@ -0,0 +1,189 @@
+/**
+ * Unit suite for `DELETE /api/workouts/{id}`.
+ *
+ * Pins:
+ * - a workout entered by hand is removed, with the personal records keyed
+ * on it, and a silent detection pass re-derives the previous best;
+ * - a synced workout answers 409 and nothing is touched, because the next
+ * sync would write it back;
+ * - another user's row and a missing row answer 404;
+ * - no session answers 401.
+ */
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { NextRequest } from "next/server";
+
+const tx = {
+ personalRecord: { deleteMany: vi.fn() },
+ workout: { delete: vi.fn() },
+};
+
+vi.mock("@/lib/db", () => ({
+ prisma: {
+ workout: { findUnique: vi.fn() },
+ $transaction: vi.fn(async (fn: (client: typeof tx) => unknown) => fn(tx)),
+ },
+}));
+vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
+vi.mock("@/lib/auth/audit", () => ({
+ auditLog: vi.fn().mockResolvedValue(undefined),
+}));
+vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
+vi.mock("@/lib/cache/invalidate", () => ({
+ invalidateUserMeasurements: vi.fn(),
+}));
+vi.mock("@/lib/jobs/pr-detection", () => ({
+ enqueuePrDetection: vi.fn().mockResolvedValue(undefined),
+}));
+vi.mock("next/headers", () => ({
+ headers: vi.fn(async () => ({ get: () => null })),
+ cookies: vi.fn(async () => ({
+ get: () => undefined,
+ set: () => {},
+ delete: () => {},
+ })),
+}));
+
+import { DELETE } from "../[id]/route";
+import { prisma } from "@/lib/db";
+import { getSession } from "@/lib/auth/session";
+import { auditLog } from "@/lib/auth/audit";
+import { enqueuePrDetection } from "@/lib/jobs/pr-detection";
+import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
+
+const SESSION_OK = {
+ session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
+ user: { id: "user-1", username: "testuser", role: "USER" as const },
+};
+
+const MANUAL_ROW = {
+ id: "w-1",
+ userId: "user-1",
+ source: "MANUAL" as const,
+ externalId: "manual:5b0f3c1e-1111-4222-8333-444455556666",
+ startedAt: new Date("2026-09-01T07:00:00Z"),
+ sportType: "running",
+};
+
+function del(id = "w-1") {
+ return DELETE(
+ new NextRequest(`http://localhost/api/workouts/${id}`, {
+ method: "DELETE",
+ }),
+ { params: Promise.resolve({ id }) },
+ );
+}
+
+describe("DELETE /api/workouts/{id}", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ tx.personalRecord.deleteMany.mockResolvedValue({ count: 0 });
+ tx.workout.delete.mockResolvedValue({ id: "w-1" });
+ });
+
+ it("removes a workout entered by hand", async () => {
+ vi.mocked(prisma.workout.findUnique).mockResolvedValue(MANUAL_ROW as never);
+
+ const res = await del();
+
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({ data: { deleted: true }, error: null });
+ expect(tx.workout.delete).toHaveBeenCalledWith({ where: { id: "w-1" } });
+ expect(auditLog).toHaveBeenCalledWith(
+ "workout.delete",
+ expect.objectContaining({ userId: "user-1" }),
+ );
+ expect(invalidateUserMeasurements).toHaveBeenCalledWith("user-1", {
+ evict: true,
+ });
+ });
+
+ it("takes the personal records the workout set with it and re-derives the best", async () => {
+ vi.mocked(prisma.workout.findUnique).mockResolvedValue(MANUAL_ROW as never);
+ tx.personalRecord.deleteMany.mockResolvedValue({ count: 2 });
+
+ const res = await del();
+
+ expect(res.status).toBe(200);
+ expect(tx.personalRecord.deleteMany).toHaveBeenCalledWith({
+ where: {
+ userId: "user-1",
+ metricSlot: { not: null },
+ source: "MANUAL",
+ externalId: MANUAL_ROW.externalId,
+ },
+ });
+ expect(enqueuePrDetection).toHaveBeenCalledWith("user-1", {
+ silent: true,
+ });
+ });
+
+ it("keys a record without an external id on the session's start", async () => {
+ vi.mocked(prisma.workout.findUnique).mockResolvedValue({
+ ...MANUAL_ROW,
+ externalId: null,
+ } as never);
+
+ await del();
+
+ expect(tx.personalRecord.deleteMany).toHaveBeenCalledWith({
+ where: {
+ userId: "user-1",
+ metricSlot: { not: null },
+ source: "MANUAL",
+ externalId: null,
+ achievedAt: MANUAL_ROW.startedAt,
+ },
+ });
+ // Nothing was removed, so nothing needs re-deriving.
+ expect(enqueuePrDetection).not.toHaveBeenCalled();
+ });
+
+ it.each(["APPLE_HEALTH", "WITHINGS", "WHOOP", "EXTERNAL"])(
+ "refuses a %s workout with 409 and touches nothing",
+ async (source) => {
+ vi.mocked(prisma.workout.findUnique).mockResolvedValue({
+ ...MANUAL_ROW,
+ source,
+ } as never);
+
+ const res = await del();
+
+ expect(res.status).toBe(409);
+ const body = await res.json();
+ expect(body.data).toBeNull();
+ expect(prisma.$transaction).not.toHaveBeenCalled();
+ expect(tx.workout.delete).not.toHaveBeenCalled();
+ expect(auditLog).not.toHaveBeenCalled();
+ },
+ );
+
+ it("answers 404 for another user's workout", async () => {
+ vi.mocked(prisma.workout.findUnique).mockResolvedValue({
+ ...MANUAL_ROW,
+ userId: "someone-else",
+ } as never);
+
+ const res = await del();
+
+ expect(res.status).toBe(404);
+ expect(prisma.$transaction).not.toHaveBeenCalled();
+ });
+
+ it("answers 404 when there is no such workout", async () => {
+ vi.mocked(prisma.workout.findUnique).mockResolvedValue(null);
+
+ const res = await del("missing");
+
+ expect(res.status).toBe(404);
+ });
+
+ it("answers 401 without a session", async () => {
+ vi.mocked(getSession).mockResolvedValue(null);
+
+ const res = await del();
+
+ expect(res.status).toBe(401);
+ expect(prisma.workout.findUnique).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/app/insights/workouts/[id]/page.tsx b/src/app/insights/workouts/[id]/page.tsx
index 3ce3a7cf9..4d8d66d64 100644
--- a/src/app/insights/workouts/[id]/page.tsx
+++ b/src/app/insights/workouts/[id]/page.tsx
@@ -10,6 +10,7 @@ import { SubPageShell } from "@/components/insights/sub-page-shell";
import { Skeleton } from "@/components/ui/skeleton";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { canonicalWorkoutDetailHref } from "@/lib/workouts/canonical-detail-route";
+import { DeleteWorkoutButton } from "@/components/workouts/delete-workout-button";
import {
WorkoutDetailHeader,
WorkoutDetailStats,
@@ -64,6 +65,11 @@ export default function InsightsWorkoutDetailPage({
// No description. The one this page used to carry belongs to the LIST
// ("Recent runs, rides, walks…, deduped…") and describes a surface
// this is not — a detail page must not claim to be the list.
+ // A workout entered by hand can be deleted here; the control renders
+ // nothing for a synced one.
+ headerAction={
+ data && !canonicalHref ? : null
+ }
backLink={
(undefined);
+ const [logOpen, setLogOpen] = useState(false);
+ const { user } = useAuth();
+ const capabilities = useRecordCapabilities();
+ const showLog = canLogWorkout(capabilities, user?.modules);
const {
workouts,
total,
@@ -80,7 +89,31 @@ export default function InsightsWorkoutsPageClient() {
description={t("insights.workouts.description")}
explainerMetric="workouts"
coachLaunch
+ headerAction={
+ showLog ? (
+ // Same 40 px ghost icon and widened hit area as the header
+ // cluster's other actions; it opens the form in place.
+
+ ) : null
+ }
>
+ {showLog ? (
+
+ ) : null}
{/* No `` here: workouts are session records, not a
MeasurementType series, so the period-over-period range read has
nothing to aggregate. */}
@@ -100,7 +133,13 @@ export default function InsightsWorkoutsPageClient() {
icon={}
title={t("insights.workouts.emptyState.title")}
description={t("insights.workouts.emptyState.description")}
- cta={null}
+ cta={
+ showLog ? (
+
+ ) : null
+ }
coachPrefill="I haven't logged any workouts yet — why does tracking them matter, and what should I focus on first?"
/>
) : (
diff --git a/src/components/__tests__/delegated-write-affordances.test.tsx b/src/components/__tests__/delegated-write-affordances.test.tsx
index 1dd578381..f8159ff8c 100644
--- a/src/components/__tests__/delegated-write-affordances.test.tsx
+++ b/src/components/__tests__/delegated-write-affordances.test.tsx
@@ -126,7 +126,11 @@ function writeCaps(
sections: ShareDomain[] | null,
) {
const writable = new Set(delegatedDomains(level, sections, "write"));
- return { canWriteDomain: (domain: ShareDomain) => writable.has(domain) };
+ return {
+ // A delegate is always inside somebody else's record.
+ inSharedRecord: true,
+ canWriteDomain: (domain: ShareDomain) => writable.has(domain),
+ };
}
const mockAccessRef: { value: AccountAccess } = { value: OWN_RECORD };
@@ -499,7 +503,7 @@ describe("the capture picker's kinds", () => {
/** The picker asks one question per kind, of that kind's own section. */
const caps = writeCaps;
- const OWNER_CAPS = { canWriteDomain: () => true };
+ const OWNER_CAPS = { inSharedRecord: false, canWriteDomain: () => true };
const WRITER_CAPS = caps("write", null);
const READER_CAPS = caps("read", null);
// A guardian: every section with a delegated route answers at MANAGE.
@@ -558,6 +562,29 @@ describe("the capture picker's kinds", () => {
);
});
+ it("offers a workout in the caller's own record, gated by the workouts module", () => {
+ expect(visibleCaptureKinds(OWNER_CAPS, ["workout"])).toEqual(["workout"]);
+ expect(
+ visibleCaptureKinds(OWNER_CAPS, ["workout"], { workouts: false }),
+ ).toEqual([]);
+ expect(
+ visibleCaptureKinds(OWNER_CAPS, ["workout"], { mood: false }),
+ ).toEqual(["workout"]);
+ });
+
+ it("never offers a delegate a workout, at any level or scope", () => {
+ // The batch ingest resolves the caller (`requireAuth`), so no grant
+ // reaches it: a measurements-scoped MANAGE grant is refused the same way.
+ for (const delegate of [
+ WRITER_CAPS,
+ READER_CAPS,
+ GUARDIAN_CAPS,
+ caps("manage", ["measurements"]),
+ ]) {
+ expect(visibleCaptureKinds(delegate, ["workout"])).toEqual([]);
+ }
+ });
+
it("offers a scoped MANAGE delegate the mood entry only with the mind section", () => {
expect(visibleCaptureKinds(caps("manage", ["mind"]), [...ALL])).toEqual([
"mood",
@@ -659,6 +686,20 @@ describe("a form opened before the record answered", () => {
);
});
+ it("withdraws the workout sheet in any shared record, and keeps it in one's own", () => {
+ for (const level of ["read", "write", "manage"] as const) {
+ expect(admittedQuickEntry("workout", writeCaps(level, null)), level).toBe(
+ null,
+ );
+ }
+ expect(
+ admittedQuickEntry("workout", {
+ inSharedRecord: false,
+ canWriteDomain: () => true,
+ }),
+ ).toBe("workout");
+ });
+
it("keeps the mood sheet for a guardian, whose mind routes answer at MANAGE", () => {
expect(admittedQuickEntry("mood", writeCaps("manage", null))).toBe("mood");
});
@@ -678,7 +719,7 @@ describe("a form opened before the record answered", () => {
});
it("leaves the owner's own sheets alone", () => {
- const OWNER_CAPS = { canWriteDomain: () => true };
+ const OWNER_CAPS = { inSharedRecord: false, canWriteDomain: () => true };
for (const sheet of ["measurement", "mood", "medicationIntake"] as const) {
expect(admittedQuickEntry(sheet, OWNER_CAPS), sheet).toBe(sheet);
}
diff --git a/src/components/dashboard/__tests__/dashboard-header.test.tsx b/src/components/dashboard/__tests__/dashboard-header.test.tsx
index 4f7ae8422..0e5579c44 100644
--- a/src/components/dashboard/__tests__/dashboard-header.test.tsx
+++ b/src/components/dashboard/__tests__/dashboard-header.test.tsx
@@ -11,7 +11,7 @@
* post-hydration name personalisation);
* 2. the SSR pass renders the name-less fallback (hydration-safe).
*/
-import { describe, it, expect, vi } from "vitest";
+import { beforeEach, describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
@@ -30,13 +30,24 @@ vi.mock("@/components/ui/dropdown-menu", () => ({
<>{children}>
),
}));
+const modulesRef: { value: Record | undefined } = {
+ value: undefined,
+};
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
- user: { username: "tester", timezone: "Europe/Berlin" },
+ user: {
+ username: "tester",
+ timezone: "Europe/Berlin",
+ modules: modulesRef.value,
+ },
}),
}));
import { DashboardHeader } from "../dashboard-header";
+import {
+ CAPTURE_KIND_ORDER,
+ visibleCaptureKinds,
+} from "@/components/layout/capture-picker";
function renderSSR(node: React.ReactElement, locale: "en" | "de" = "de") {
return renderToStaticMarkup(
@@ -85,3 +96,88 @@ describe(" — quick-add menu", () => {
expect(html).not.toContain("Log water");
});
});
+
+describe(" — Log workout", () => {
+ beforeEach(() => {
+ modulesRef.value = undefined;
+ });
+
+ it("offers it beside the other capture entries", () => {
+ modulesRef.value = { workouts: true };
+ const html = renderSSR(
+ undefined} />,
+ "en",
+ );
+ expect(html).toContain("Log workout");
+ });
+
+ it("withholds it when the workouts module is off, and keeps the rest", () => {
+ modulesRef.value = { workouts: false };
+ const html = renderSSR(
+ undefined} />,
+ "en",
+ );
+ expect(html).not.toContain("Log workout");
+ expect(html).toContain("Log measurement");
+ });
+});
+
+describe(" — every entry follows its module, as the capture picker does", () => {
+ beforeEach(() => {
+ modulesRef.value = undefined;
+ });
+
+ it("withholds Log mood when the mood module is off", () => {
+ modulesRef.value = { mood: false };
+ const html = renderSSR(
+ undefined} />,
+ "en",
+ );
+ expect(html).not.toContain("Log mood");
+ expect(html).toContain("Log medication intake");
+ });
+
+ it("withholds Log medication intake when the medications module is off", () => {
+ modulesRef.value = { medications: false };
+ const html = renderSSR(
+ undefined} />,
+ "en",
+ );
+ expect(html).not.toContain("Log medication intake");
+ expect(html).toContain("Log mood");
+ });
+
+ it("offers exactly what the capture picker offers for the same modules", () => {
+ const cases: Array | undefined> = [
+ undefined,
+ { mood: false },
+ { medications: false },
+ { workouts: false },
+ { mood: false, medications: false, workouts: false },
+ ];
+ for (const modules of cases) {
+ modulesRef.value = modules;
+ const html = renderSSR(
+ undefined} />,
+ "en",
+ );
+ const picker = visibleCaptureKinds(
+ { inSharedRecord: false, canWriteDomain: () => true },
+ CAPTURE_KIND_ORDER,
+ modules,
+ );
+ const label = {
+ measurement: "Log measurement",
+ medication: "Log medication intake",
+ mood: "Log mood",
+ workout: "Log workout",
+ } as const;
+ for (const kind of CAPTURE_KIND_ORDER) {
+ expect(
+ html.includes(label[kind]),
+ `${kind} ${JSON.stringify(modules)}`,
+ ).toBe(picker.includes(kind));
+ }
+ }
+ });
+});
diff --git a/src/components/dashboard/dashboard-header.tsx b/src/components/dashboard/dashboard-header.tsx
index 1e0b554cb..db6ad7cc0 100644
--- a/src/components/dashboard/dashboard-header.tsx
+++ b/src/components/dashboard/dashboard-header.tsx
@@ -12,7 +12,7 @@
*/
import { useMemo } from "react";
import Link from "next/link";
-import { Activity, Pill, Plus, Waves, Wrench } from "lucide-react";
+import { Activity, Footprints, Pill, Plus, Waves, Wrench } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@@ -26,6 +26,10 @@ import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { hourInTz } from "@/lib/tz/format";
+import {
+ CAPTURE_KIND_ORDER,
+ visibleCaptureKinds,
+} from "@/components/layout/capture-picker";
import type { QuickEntryDialog } from "@/components/dashboard/quick-entry-sheets";
export function DashboardHeader({
@@ -36,19 +40,30 @@ export function DashboardHeader({
const { t } = useTranslations();
const { user } = useAuth();
const mounted = useMounted();
- // The quick-add offers three kinds, and each one is asked about its own
+ // The quick-add offers four kinds, and each one is asked about its own
// section: a reading under `measurements`, a dose under `medications`, a
- // mood entry under `mind`. The coarse `canAdd` this used to gate the whole
+ // mood entry under `mind`, a workout in one's own record only. The coarse `canAdd` this used to gate the whole
// menu on carries the grant's LEVEL and no scope, so it answered true for a
// grant that opens neither of the two sections the menu never asked about.
// The customize shortcut points at a settings page sharing does not cover at
// all.
- const { canWriteDomain, inSharedRecord } = useRecordCapabilities();
- const canAddMeasurement = canWriteDomain("measurements");
- const canAddIntake = canWriteDomain("medications");
- // A mood entry is created at MANAGE under the mind section.
- const canAddMood = canWriteDomain("mind");
- const canAddAnything = canAddMeasurement || canAddIntake || canAddMood;
+ const capabilities = useRecordCapabilities();
+ const { inSharedRecord } = capabilities;
+ // The same answer the bottom bar's capture picker renders from: per kind,
+ // the record's section (a delegated grant) AND the kind's module
+ // (`capture:`). Asking the picker's own function is what keeps the two
+ // menus from disagreeing — this one used to check the section alone, so a
+ // switched-off mood module still offered "Log mood" here.
+ const offered = visibleCaptureKinds(
+ capabilities,
+ CAPTURE_KIND_ORDER,
+ user?.modules,
+ );
+ const canAddMeasurement = offered.includes("measurement");
+ const canAddIntake = offered.includes("medication");
+ const canAddMood = offered.includes("mood");
+ const canAddWorkout = offered.includes("workout");
+ const canAddAnything = offered.length > 0;
// The pre-hero greeting derivation, kept hydration-safe: `user` comes
// from the auth query, which can resolve before this boundary
@@ -183,6 +198,12 @@ export function DashboardHeader({
{t("dashboard.quickAddMedicationIntake")}
)}
+ {canAddWorkout && (
+ onQuickEntry("workout")}>
+
+ {t("dashboard.quickAddWorkout")}
+
+ )}
)}
diff --git a/src/components/dashboard/quick-entry-sheets.tsx b/src/components/dashboard/quick-entry-sheets.tsx
index 189958163..cd294a11a 100644
--- a/src/components/dashboard/quick-entry-sheets.tsx
+++ b/src/components/dashboard/quick-entry-sheets.tsx
@@ -24,6 +24,7 @@ import {
import { MeasurementForm } from "@/components/measurements/measurement-form";
import { MoodForm } from "@/components/mood/mood-form";
import { MedicationIntakeQuickAdd } from "@/components/dashboard/medication-intake-quick-add";
+import { ManualWorkoutForm } from "@/components/workouts/manual-workout-form";
import { useTranslations } from "@/lib/i18n/context";
import {
useRecordCapabilities,
@@ -32,15 +33,20 @@ import {
import type { ShareDomain } from "@/lib/sharing/scope";
export type QuickEntryDialog =
- "measurement" | "mood" | "medicationIntake" | null;
+ "measurement" | "mood" | "medicationIntake" | "workout" | null;
-/** The section each sheet writes to; see the capture picker. */
+/**
+ * The section each sheet writes to; see the capture picker. `null` marks a
+ * sheet whose route resolves the caller rather than the record, which no
+ * grant reaches: a workout goes through the batch ingest, own record only.
+ */
const QUICK_ENTRY_DOMAIN: Readonly<
- Record, ShareDomain>
+ Record, ShareDomain | null>
> = {
measurement: "measurements",
medicationIntake: "medications",
mood: "mind",
+ workout: null,
};
/**
@@ -59,10 +65,13 @@ const QUICK_ENTRY_DOMAIN: Readonly<
*/
export function admittedQuickEntry(
open: QuickEntryDialog,
- caps: Pick,
+ caps: Pick,
): QuickEntryDialog {
if (open === null) return open;
- return caps.canWriteDomain(QUICK_ENTRY_DOMAIN[open]) ? open : null;
+ const domain = QUICK_ENTRY_DOMAIN[open];
+ const admitted =
+ domain === null ? !caps.inSharedRecord : caps.canWriteDomain(domain);
+ return admitted ? open : null;
}
/**
@@ -151,6 +160,9 @@ export function QuickEntrySheets({
// mobile soft keyboard.
const [medicationIntakeFooterEl, setMedicationIntakeFooterEl] =
useState(null);
+ const [workoutFooterEl, setWorkoutFooterEl] = useState(
+ null,
+ );
// v1.11.3 F3 — when an open quick-entry sheet is dismissed with
// unsaved input, hold the close in this flag and surface a confirm
// instead of nulling the dialog outright. Cleared once the user
@@ -214,6 +226,24 @@ export function QuickEntrySheets({
/>
+ {/* Log a workout by hand. Mounted only while open, so every opening
+ is a fresh form with its own external id. */}
+ }
+ >
+ {openSheet === "workout" && (
+
+ )}
+
+
{/* v1.11.3 F3 — confirm before discarding a partly-filled
quick-entry form when the sheet is dismissed by an overlay
tap, Escape, or a mobile swipe-down. Keeps the sheet open
diff --git a/src/components/insights/sub-page-shell.tsx b/src/components/insights/sub-page-shell.tsx
index 8170bbf00..744c764d2 100644
--- a/src/components/insights/sub-page-shell.tsx
+++ b/src/components/insights/sub-page-shell.tsx
@@ -151,6 +151,13 @@ export interface SubPageShellProps {
* at the page foot.
*/
showAllValuesType?: string;
+ /**
+ * A page-owned action that opens something in place rather than linking
+ * away (the workouts page's "Log workout"). Leads the header cluster, in
+ * the same 40 px ghost-icon shape as its siblings; the page owns the
+ * control and whatever it opens.
+ */
+ headerAction?: ReactNode;
children: ReactNode;
}
@@ -171,6 +178,7 @@ export function SubPageShell({
coachLaunch = false,
captureType,
showAllValuesType,
+ headerAction,
children,
}: SubPageShellProps) {
const { t } = useTranslations();
@@ -309,6 +317,7 @@ export function SubPageShell({
with a tighter gap the later sibling's invisible halo sat
on top of its neighbour's clickable edge. */}