Merge pull request #1089 from MBombeck/feat/manual-workout-entry

Log a workout by hand
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-10-02 23:59:11 +02:00
commit 2473d52f19
31 files changed
+2579 -61

No files matched your search

+2 -2
View File
@@ -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 /inLine truncated
"$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 /inLine truncated
"routeBaselineKbGz": {
"/page": 447,
"/insights/page": 434,
@@ -7,6 +7,6 @@
"/insights/mood/page": 447
},
"routeDriftAllowanceKbGz": 20,
"totalClientKbGz": 3724,
"totalClientKbGz": 3749,
"maxRechartsChunks": 1
}
+67 -6
View File
@@ -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:
+34 -3
View File
@@ -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",
+34 -3
View File
@@ -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",
+34 -3
View File
@@ -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",
+34 -3
View File
@@ -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",
+34 -3
View File
@@ -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",
+34 -3
View File
@@ -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": "혈당",
+34 -3
View File
@@ -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",
@@ -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 ─────────────────────────────────────────
{
+109 -1
View File
@@ -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 });
},
);
@@ -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();
});
});
+6
View File
@@ -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 ? <DeleteWorkoutButton workout={data} /> : null
}
backLink={
<BackLink
href="/insights/workouts"
+41 -2
View File
@@ -1,7 +1,7 @@
"use client";
import { useMemo, useState } from "react";
import { Activity } from "lucide-react";
import { Activity, Plus } from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
@@ -10,6 +10,11 @@ import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { WorkoutList } from "@/components/insights/workout-list";
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
import { ManualWorkoutSheet } from "@/components/workouts/manual-workout-sheet";
import { useAuth } from "@/hooks/use-auth";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { canLogWorkout } from "@/lib/workouts/manual-entry";
import { QueryErrorCard } from "@/components/ui/query-error-card";
/**
@@ -42,6 +47,10 @@ import { QueryErrorCard } from "@/components/ui/query-error-card";
export default function InsightsWorkoutsPageClient() {
const { t, tCount } = useTranslations();
const [sportType, setSportType] = useState<string | undefined>(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.
<Button
type="button"
variant="ghost"
size="icon"
data-slot="workout-log-manual"
aria-label={t("insights.workouts.manual.logWorkout")}
title={t("insights.workouts.manual.logWorkout")}
onClick={() => setLogOpen(true)}
className={cn(
"text-muted-foreground hover:text-foreground relative size-10",
"before:absolute before:-inset-1.5 before:content-['']",
)}
>
<Plus className="size-4" aria-hidden="true" />
</Button>
) : null
}
>
{showLog ? (
<ManualWorkoutSheet open={logOpen} onOpenChange={setLogOpen} />
) : null}
{/* No `<MetricRangeControls>` 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={<Activity className="size-6" />}
title={t("insights.workouts.emptyState.title")}
description={t("insights.workouts.emptyState.description")}
cta={null}
cta={
showLog ? (
<Button size="sm" type="button" onClick={() => setLogOpen(true)}>
{t("insights.workouts.manual.logWorkout")}
</Button>
) : null
}
coachPrefill="I haven't logged any workouts yet — why does tracking them matter, and what should I focus on first?"
/>
) : (
@@ -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);
}
@@ -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<string, boolean> | 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("<DashboardHeader> — quick-add menu", () => {
expect(html).not.toContain("Log water");
});
});
describe("<DashboardHeader> — Log workout", () => {
beforeEach(() => {
modulesRef.value = undefined;
});
it("offers it beside the other capture entries", () => {
modulesRef.value = { workouts: true };
const html = renderSSR(
<DashboardHeader onQuickEntry={() => 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(
<DashboardHeader onQuickEntry={() => undefined} />,
"en",
);
expect(html).not.toContain("Log workout");
expect(html).toContain("Log measurement");
});
});
describe("<DashboardHeader> — 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(
<DashboardHeader onQuickEntry={() => 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(
<DashboardHeader onQuickEntry={() => 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<Record<string, boolean> | 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(
<DashboardHeader onQuickEntry={() => 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));
}
}
});
});
+30 -9
View File
@@ -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:<kind>`). 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")}
</DropdownMenuItem>
)}
{canAddWorkout && (
<DropdownMenuItem onClick={() => onQuickEntry("workout")}>
<Footprints className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddWorkout")}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
@@ -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<NonNullable<QuickEntryDialog>, ShareDomain>
Record<NonNullable<QuickEntryDialog>, 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<RecordCapabilities, "canWriteDomain">,
caps: Pick<RecordCapabilities, "canWriteDomain" | "inSharedRecord">,
): 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<HTMLDivElement | null>(null);
const [workoutFooterEl, setWorkoutFooterEl] = useState<HTMLDivElement | null>(
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({
/>
</ResponsiveSheet>
{/* Log a workout by hand. Mounted only while open, so every opening
is a fresh form with its own external id. */}
<ResponsiveSheet
open={openSheet === "workout"}
onOpenChange={handleQuickEntryOpenChange}
title={t("insights.workouts.manual.sheetTitle")}
description={t("insights.workouts.manual.sheetDescription")}
footer={<div ref={setWorkoutFooterEl} className="flex w-full" />}
>
{openSheet === "workout" && (
<ManualWorkoutForm
onSuccess={onClose}
onCancel={onClose}
footerSlot={workoutFooterEl}
/>
)}
</ResponsiveSheet>
{/* 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
@@ -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. */}
<div className="flex shrink-0 items-center gap-3">
{headerAction}
{captureHref ? (
<Button
asChild
+31 -10
View File
@@ -1,11 +1,12 @@
"use client";
import { useState } from "react";
import { Activity, Pill, Waves } from "lucide-react";
import { Activity, Footprints, Pill, Waves } from "lucide-react";
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 { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import {
AlertDialog,
@@ -30,11 +31,12 @@ import {
/**
* The center "Log" capture action (iOS parity — the bottom bar's
* middle slot is a capture CTA, not a destination). It opens a small
* picker that routes to three existing quick-entry surfaces:
* picker that routes to four existing quick-entry surfaces:
*
* - Measurement → `<MeasurementForm>` (the `/measurements` add form)
* - Medication → `<MedicationIntakeQuickAdd>` (dashboard quick-add)
* - Mood → `<MoodForm>` (the `/mood` add form, 5-face flow)
* - Workout → `<ManualWorkoutForm>` (the workouts page's "Log workout")
*
* The picker reuses each existing capture component rather than
* rebuilding parallel forms.
@@ -50,7 +52,7 @@ import {
* instead of closing unconditionally.
*/
type CaptureKind = "measurement" | "medication" | "mood";
type CaptureKind = "measurement" | "medication" | "mood" | "workout";
/**
* The section each capture surface writes to.
@@ -66,10 +68,13 @@ type CaptureKind = "measurement" | "medication" | "mood";
* so asking it per section answers both cases and the mood entry's
* MANAGE-only create as well.
*/
const CAPTURE_KIND_DOMAIN: Readonly<Record<CaptureKind, ShareDomain>> = {
const CAPTURE_KIND_DOMAIN: Readonly<Record<CaptureKind, ShareDomain | null>> = {
measurement: "measurements",
medication: "medications",
mood: "mind",
// A workout goes through the batch ingest, which resolves the caller:
// no grant reaches it, so it is offered in one's own record only.
workout: null,
};
/** The order the chooser lists them in. */
@@ -77,6 +82,7 @@ export const CAPTURE_KIND_ORDER: ReadonlyArray<CaptureKind> = [
"measurement",
"medication",
"mood",
"workout",
];
/**
@@ -118,15 +124,16 @@ export function admittedCaptureKind(
* kind the record allows, the gate's default-on contract.
*/
export function visibleCaptureKinds(
caps: Pick<RecordCapabilities, "canWriteDomain">,
caps: Pick<RecordCapabilities, "canWriteDomain" | "inSharedRecord">,
kinds: ReadonlyArray<CaptureKind>,
modules?: SurfaceModuleMap | null,
): CaptureKind[] {
return kinds.filter(
(kind) =>
caps.canWriteDomain(CAPTURE_KIND_DOMAIN[kind]) &&
isSurfaceVisible(`capture:${kind}`, modules),
);
return kinds.filter((kind) => {
const domain = CAPTURE_KIND_DOMAIN[kind];
const writable =
domain === null ? !caps.inSharedRecord : caps.canWriteDomain(domain);
return writable && isSurfaceVisible(`capture:${kind}`, modules);
});
}
interface CapturePickerProps {
@@ -203,6 +210,12 @@ export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
description: t("nav.capture.moodDescription"),
icon: Waves,
},
{
kind: "workout",
label: t("nav.capture.workout"),
description: t("nav.capture.workoutDescription"),
icon: Footprints,
},
];
const options = allOptions.filter((opt) => offered.includes(opt.kind));
@@ -210,6 +223,7 @@ export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
measurement: t("measurements.addMeasurement"),
medication: t("nav.capture.medication"),
mood: t("mood.addEntry"),
workout: t("insights.workouts.manual.sheetTitle"),
};
// `openKind === null` keeps the form sheet closed (the title is unread
// then); the mood label is the harmless default, matching the prior
@@ -272,6 +286,13 @@ export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
footerSlot={footerEl}
/>
)}
{openKind === "workout" && (
<ManualWorkoutForm
onSuccess={closeForm}
onCancel={closeForm}
footerSlot={footerEl}
/>
)}
{openKind === "mood" && (
<MoodForm
onSuccess={closeForm}
@@ -0,0 +1,241 @@
/**
* `<ManualWorkoutForm>` and its save path, and the detail page's delete.
*
* SSR-only, like the other capture-form suites (vitest runs in `node`, no
* testing-library). The rules themselves live in `manual-entry.ts` and are
* pinned in its own suite; this one pins the wire and the markup:
*
* 1. the save posts ONE entry to `POST /api/workouts/batch`, MANUAL, with
* the form's external id, and refreshes the workout reads;
* 2. a `duplicate` (a second tap) is a success, a `skipped` entry is not;
* 3. the form renders its five fields, the distance in the reader's unit,
* and a layout that stacks distance / energy on a phone;
* 4. delete is offered for a hand-entered workout in one's own record only,
* and calls `DELETE /api/workouts/{id}`.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import type { QueryClient } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
}));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
const authRef: { unitPreference: "metric" | "imperial" } = {
unitPreference: "metric",
};
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: {
username: "tester",
timezone: "Europe/Berlin",
unitPreference: authRef.unitPreference,
},
}),
}));
import {
ManualWorkoutForm,
saveManualWorkout,
sportOptions,
} from "../manual-workout-form";
import {
canDeleteWorkout,
deleteManualWorkout,
} from "../delete-workout-button";
import type { ManualWorkoutEntry } from "@/lib/workouts/manual-entry";
const ENTRY: ManualWorkoutEntry = {
sportType: "cycling",
startedAt: "2026-09-15T06:00:00.000Z",
endedAt: "2026-09-15T07:30:00.000Z",
source: "MANUAL",
externalId: "manual:5b0f3c1e-1111-4222-8333-444455556666",
totalDistanceM: 32000,
};
function envelope(data: unknown, status = 200) {
return new Response(JSON.stringify({ data, error: null }), {
status,
headers: { "content-type": "application/json" },
});
}
function fakeClient() {
return {
invalidateQueries: vi.fn().mockResolvedValue(undefined),
removeQueries: vi.fn(),
};
}
function render(node: React.ReactElement) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
let fetchSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
authRef.unitPreference = "metric";
fetchSpy = vi.spyOn(globalThis, "fetch");
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("saveManualWorkout — the wire", () => {
it("posts one MANUAL entry with the form's external id to the batch route", async () => {
fetchSpy.mockResolvedValue(
envelope({ entries: [{ index: 0, status: "inserted" }] }),
);
const client = fakeClient();
const outcome = await saveManualWorkout(
ENTRY,
client as unknown as QueryClient,
);
expect(outcome).toBe("inserted");
expect(fetchSpy).toHaveBeenCalledTimes(1);
const [url, init] = fetchSpy.mock.calls[0]!;
expect(String(url)).toBe("/api/workouts/batch");
expect((init as RequestInit).method).toBe("POST");
const body = JSON.parse(String((init as RequestInit).body));
expect(body).toEqual({ workouts: [ENTRY] });
expect(body.workouts[0].source).toBe("MANUAL");
expect(body.workouts[0].externalId).toBe(ENTRY.externalId);
});
it("refreshes every workout read, and the daily reads", async () => {
fetchSpy.mockResolvedValue(
envelope({ entries: [{ index: 0, status: "inserted" }] }),
);
const client = fakeClient();
await saveManualWorkout(ENTRY, client as unknown as QueryClient);
const keys = client.invalidateQueries.mock.calls.map(([arg]) =>
JSON.stringify((arg as { queryKey: unknown }).queryKey),
);
expect(keys).toContain(JSON.stringify(queryKeys.workouts()));
expect(keys).toContain(JSON.stringify(queryKeys.dailyDigest()));
expect(keys).toContain(JSON.stringify(queryKeys.dashboardSnapshot()));
});
it("treats a duplicate (the same form submitted twice) as saved", async () => {
fetchSpy.mockResolvedValue(
envelope({ entries: [{ index: 0, status: "duplicate" }] }),
);
await expect(
saveManualWorkout(ENTRY, fakeClient() as unknown as QueryClient),
).resolves.toBe("duplicate");
});
it("throws when the route skipped the entry, and refreshes nothing", async () => {
fetchSpy.mockResolvedValue(
envelope({ entries: [{ index: 0, status: "skipped" }] }),
);
const client = fakeClient();
await expect(
saveManualWorkout(ENTRY, client as unknown as QueryClient),
).rejects.toThrow();
expect(client.invalidateQueries).not.toHaveBeenCalled();
});
});
describe("sportOptions", () => {
it("lists every sport by label, with Other last", () => {
const options = sportOptions((sport) => sport.toUpperCase());
expect(options).toHaveLength(21);
expect(options.at(-1)).toEqual({ value: "other", label: "OTHER" });
const labels = options.slice(0, -1).map((o) => o.label);
expect(labels).toEqual([...labels].sort((a, b) => a.localeCompare(b)));
});
});
describe("<ManualWorkoutForm> — markup", () => {
it("renders the five fields and nothing else", () => {
const html = render(<ManualWorkoutForm />);
expect(html).toContain('data-testid="manual-workout-sport"');
expect(html).toContain('data-testid="manual-workout-start"');
expect(html).toContain('data-testid="manual-workout-hours"');
expect(html).toContain('data-testid="manual-workout-minutes"');
expect(html).toContain('data-testid="manual-workout-distance"');
expect(html).toContain('data-testid="manual-workout-energy"');
expect(html).toContain("Activity");
expect(html).toContain("Duration");
expect(html).toContain("Active energy (kcal)");
// No heart rate, no sets, no route: a record, not a training log.
expect(html).not.toMatch(/heart|bpm|sets|reps/i);
});
it("names the distance in kilometres for a metric reader", () => {
expect(render(<ManualWorkoutForm />)).toContain("Distance (km)");
});
it("names the distance in miles for an imperial reader", () => {
authRef.unitPreference = "imperial";
expect(render(<ManualWorkoutForm />)).toContain("Distance (mi)");
});
it("keeps hours and minutes side by side and stacks distance and energy on a phone", () => {
const html = render(<ManualWorkoutForm />);
// Two short numeric fields fit a 390 px sheet side by side.
expect(html).toMatch(/class="grid grid-cols-2 gap-4"/);
// Distance and energy carry longer labels: one column below `sm`, two
// from 640 px up.
expect(html).toMatch(/class="grid gap-4 sm:grid-cols-2"/);
// The action buttons keep the 44 px touch floor on a phone.
expect(html).toContain("min-h-11 sm:min-h-9");
});
});
describe("delete — a hand-entered workout in one's own record only", () => {
it("is offered for a MANUAL workout in one's own record", () => {
expect(
canDeleteWorkout({ source: "MANUAL" }, { inSharedRecord: false }),
).toBe(true);
});
it.each([
"APPLE_HEALTH",
"WITHINGS",
"WHOOP",
"FITBIT",
"STRAVA",
"EXTERNAL",
])("is not offered for a %s workout", (source) => {
expect(canDeleteWorkout({ source }, { inSharedRecord: false })).toBe(false);
});
it("is not offered inside somebody else's record", () => {
expect(
canDeleteWorkout({ source: "MANUAL" }, { inSharedRecord: true }),
).toBe(false);
});
it("calls DELETE on the workout and drops its detail from the cache", async () => {
fetchSpy.mockResolvedValue(envelope({ deleted: true }));
const client = fakeClient();
await deleteManualWorkout("w-1", client as unknown as QueryClient);
const [url, init] = fetchSpy.mock.calls[0]!;
expect(String(url)).toBe("/api/workouts/w-1");
expect((init as RequestInit).method).toBe("DELETE");
expect(client.removeQueries).toHaveBeenCalledWith({
queryKey: queryKeys.workoutDetail("w-1"),
});
expect(client.invalidateQueries).toHaveBeenCalledWith({
queryKey: queryKeys.workouts(),
});
});
});
@@ -0,0 +1,138 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useQueryClient, type QueryClient } from "@tanstack/react-query";
import { Loader2, Trash2 } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { apiDelete } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys, refetchInactiveDailyReads } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
/**
* Whether a workout can be deleted from its detail page: one entered by hand,
* in one's own record. A synced workout would come back with the next sync,
* and `DELETE /api/workouts/{id}` refuses it (409); the route also resolves
* the caller, so a delegate is never offered it.
*/
export function canDeleteWorkout(
workout: { source: string },
caps: { inSharedRecord: boolean },
): boolean {
return workout.source === "MANUAL" && !caps.inSharedRecord;
}
/**
* Delete, then drop the deleted detail from the cache and refresh the rest of
* the workout reads (list, dashboard tile) and the two daily reads the route
* evicted server-side. The detail is removed rather than invalidated, so
* nothing refetches a row that no longer exists.
*/
export async function deleteManualWorkout(
id: string,
queryClient: QueryClient,
): Promise<void> {
await apiDelete(`/api/workouts/${encodeURIComponent(id)}`);
queryClient.removeQueries({ queryKey: queryKeys.workoutDetail(id) });
await queryClient.invalidateQueries({ queryKey: queryKeys.workouts() });
await refetchInactiveDailyReads(queryClient);
}
/**
* The detail page's delete control: a ghost icon in the page header cluster
* that asks before it deletes. Renders nothing for a workout it may not
* delete.
*/
export function DeleteWorkoutButton({
workout,
}: {
workout: { id: string; source: string };
}) {
const { t } = useTranslations();
const router = useRouter();
const queryClient = useQueryClient();
const capabilities = useRecordCapabilities();
const [open, setOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
if (!canDeleteWorkout(workout, capabilities)) return null;
async function handleDelete(e: React.MouseEvent) {
// Keep the dialog open while the request runs; it closes on success.
e.preventDefault();
if (deleting) return;
setDeleting(true);
try {
await deleteManualWorkout(workout.id, queryClient);
toastWrittenOutcome("success", t("insights.workouts.manual.deleted"));
setOpen(false);
router.replace("/insights/workouts");
} catch {
toastWrittenOutcome("failed", t("insights.workouts.manual.deleteError"));
} finally {
setDeleting(false);
}
}
return (
<>
<Button
type="button"
variant="ghost"
size="icon"
data-slot="workout-delete"
aria-label={t("insights.workouts.manual.deleteLabel")}
title={t("insights.workouts.manual.deleteLabel")}
onClick={() => setOpen(true)}
className={cn(
"text-muted-foreground hover:text-destructive relative size-10",
"before:absolute before:-inset-1.5 before:content-['']",
)}
>
<Trash2 className="size-4" aria-hidden="true" />
</Button>
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("insights.workouts.manual.deleteConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("insights.workouts.manual.deleteConfirmDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={handleDelete}
disabled={deleting}
data-testid="workout-delete-confirm"
>
{deleting ? (
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -0,0 +1,392 @@
"use client";
import { useId, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { useQueryClient, type QueryClient } from "@tanstack/react-query";
import { Loader2, Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { DateTimeField } from "@/components/ui/date-time-field";
import { FieldError } from "@/components/forms/field-error";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useAuth } from "@/hooks/use-auth";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { ApiError, apiPost } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { getQuantityTransform } from "@/lib/measurements/display-transform";
import { queryKeys, refetchInactiveDailyReads } from "@/lib/query-keys";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import {
buildManualWorkoutEntry,
emptyManualWorkoutDraft,
newManualWorkoutExternalId,
wallClockNow,
type ManualWorkoutDraft,
type ManualWorkoutEntry,
type ManualWorkoutField,
} from "@/lib/workouts/manual-entry";
import {
workoutSportTypeEnum,
type WorkoutSportType,
} from "@/lib/validations/workout";
/**
* Log a workout by hand: sport, start, duration, and optionally distance and
* active energy. Nothing else on purpose; see `manual-entry.ts`.
*
* Same contract as the other capture forms (`MoodForm`,
* `MedicationIntakeQuickAdd`): mounted inside a `ResponsiveSheet`, the action
* row portalled into the sheet's sticky footer through `footerSlot`, and
* `onSuccess` / `onCancel` close the sheet.
*/
interface ManualWorkoutFormProps {
onSuccess?: () => void;
onCancel?: () => void;
footerSlot?: HTMLElement | null;
}
interface BatchResult {
entries?: Array<{ status: string }>;
}
export type ManualWorkoutSaveOutcome = "inserted" | "duplicate";
/**
* Post the entry and refresh what shows workouts. A `duplicate` is a success:
* the same form already landed this session (a second tap, a retry), so the
* row exists exactly once. A `skipped` entry was refused and throws.
*/
export async function saveManualWorkout(
entry: ManualWorkoutEntry,
queryClient: QueryClient,
): Promise<ManualWorkoutSaveOutcome> {
const result = await apiPost<BatchResult>("/api/workouts/batch", {
workouts: [entry],
});
const status = result?.entries?.[0]?.status;
if (status !== "inserted" && status !== "duplicate") {
throw new Error(`workout entry not stored: ${status ?? "unknown"}`);
}
// The list, the dashboard tile and every detail read sit under this
// prefix. The form also opens from the capture picker on any page, where
// the Today hero (which names a workout that just landed) and the dashboard
// snapshot are unmounted, so those two are refetched outright.
await queryClient.invalidateQueries({ queryKey: queryKeys.workouts() });
await refetchInactiveDailyReads(queryClient);
return status;
}
/** The sports, by their label in the reader's language, "Other" last. */
export function sportOptions(
label: (sport: WorkoutSportType) => string,
): Array<{ value: WorkoutSportType; label: string }> {
const named = workoutSportTypeEnum.options
.filter((sport) => sport !== "other")
.map((value): { value: WorkoutSportType; label: string } => ({
value,
label: label(value),
}))
.sort((a, b) => a.label.localeCompare(b.label));
return [...named, { value: "other", label: label("other") }];
}
export function ManualWorkoutForm({
onSuccess,
onCancel,
footerSlot,
}: ManualWorkoutFormProps) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const { user } = useAuth();
const { preference } = useUnitDisplay();
const timezone = user?.timezone || DEFAULT_TIMEZONE;
// Minted once per opened form and sent with every submit, so a second tap
// or a retried request lands as a duplicate rather than a second row.
const [externalId] = useState(newManualWorkoutExternalId);
const [draft, setDraft] = useState<ManualWorkoutDraft>(() =>
emptyManualWorkoutDraft(new Date(), timezone),
);
const [errors, setErrors] = useState<
Partial<Record<ManualWorkoutField, string>>
>({});
const [saveError, setSaveError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const formId = useId();
const ids = {
sport: `${formId}-sport`,
start: `${formId}-start`,
hours: `${formId}-hours`,
minutes: `${formId}-minutes`,
distance: `${formId}-distance`,
energy: `${formId}-energy`,
durationError: `${formId}-duration-error`,
saveError: `${formId}-save-error`,
};
const distanceUnit = getQuantityTransform("distance", preference).displayUnit;
const options = useMemo(
() => sportOptions((sport) => t(`insights.workouts.sport.${sport}`)),
[t],
);
function update<K extends keyof ManualWorkoutDraft>(
key: K,
value: ManualWorkoutDraft[K],
) {
setDraft((prev) => ({ ...prev, [key]: value }));
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (saving) return;
setSaveError(null);
const built = buildManualWorkoutEntry(draft, {
timezone,
unitPreference: preference,
externalId,
now: new Date(),
});
if (!built.ok) {
setErrors(built.errors);
return;
}
setErrors({});
setSaving(true);
try {
const outcome = await saveManualWorkout(built.entry, queryClient);
// A duplicate wrote nothing new: the same form already landed this
// session. That is reported as such, not as a fresh save.
if (outcome === "duplicate") {
toastWrittenOutcome(
"empty",
t("insights.workouts.manual.alreadySaved"),
);
} else {
toastWrittenOutcome("success", t("common.saved"));
}
onSuccess?.();
} catch (err) {
setSaveError(
err instanceof ApiError && err.message
? err.message
: t("insights.workouts.manual.saveError"),
);
} finally {
setSaving(false);
}
}
const fieldError = (field: ManualWorkoutField) =>
errors[field] ? t(errors[field]) : undefined;
const describedBy = (field: ManualWorkoutField, id: string) =>
errors[field] ? id : undefined;
const footerNode = (
<div className="flex w-full items-center justify-end gap-2">
{onCancel && (
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={saving}
className="min-h-11 sm:min-h-9"
>
{t("common.cancel")}
</Button>
)}
<Button
type="submit"
form={formId}
disabled={saving}
className="min-h-11 sm:min-h-9"
data-testid="manual-workout-save"
>
{saving ? (
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
) : (
<Plus className="size-4" />
)}
{t("common.save")}
</Button>
</div>
);
return (
<form
id={formId}
onSubmit={handleSubmit}
noValidate
className="space-y-4"
data-testid="manual-workout-form"
>
<div className="space-y-2">
<Label htmlFor={ids.sport}>
{t("insights.workouts.manual.sportLabel")}
</Label>
<Select
value={draft.sportType}
onValueChange={(value) =>
update("sportType", value as WorkoutSportType)
}
>
<SelectTrigger
id={ids.sport}
className="w-full"
aria-invalid={!!errors.sportType || undefined}
aria-describedby={describedBy("sportType", `${ids.sport}-error`)}
data-testid="manual-workout-sport"
>
<SelectValue
placeholder={t("insights.workouts.manual.sportPlaceholder")}
/>
</SelectTrigger>
<SelectContent>
{options.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError
id={`${ids.sport}-error`}
message={fieldError("sportType")}
/>
</div>
<div className="space-y-2">
<Label htmlFor={ids.start}>
{t("insights.workouts.manual.startLabel")}
</Label>
<DateTimeField
id={ids.start}
value={draft.start}
onChange={(value) => update("start", value)}
max={wallClockNow(new Date(), timezone)}
required
aria-required="true"
aria-invalid={!!errors.start || undefined}
aria-describedby={describedBy("start", `${ids.start}-error`)}
data-testid="manual-workout-start"
/>
<FieldError id={`${ids.start}-error`} message={fieldError("start")} />
</div>
<fieldset
className="space-y-2"
aria-describedby={describedBy("duration", ids.durationError)}
>
<legend className="mb-2 text-sm leading-none font-medium">
{t("insights.workouts.manual.durationLabel")}
</legend>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label
htmlFor={ids.hours}
className="text-muted-foreground text-xs font-normal"
>
{t("common.hours")}
</Label>
<Input
id={ids.hours}
inputMode="numeric"
autoComplete="off"
value={draft.hours}
onChange={(e) => update("hours", e.target.value)}
aria-invalid={!!errors.duration || undefined}
data-testid="manual-workout-hours"
/>
</div>
<div className="space-y-1.5">
<Label
htmlFor={ids.minutes}
className="text-muted-foreground text-xs font-normal"
>
{t("common.minutes")}
</Label>
<Input
id={ids.minutes}
inputMode="numeric"
autoComplete="off"
value={draft.minutes}
onChange={(e) => update("minutes", e.target.value)}
aria-invalid={!!errors.duration || undefined}
data-testid="manual-workout-minutes"
/>
</div>
</div>
<FieldError id={ids.durationError} message={fieldError("duration")} />
</fieldset>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={ids.distance}>
{t("insights.workouts.manual.distanceLabel", {
unit: distanceUnit,
})}
</Label>
<Input
id={ids.distance}
inputMode="decimal"
autoComplete="off"
placeholder={t("common.optional")}
value={draft.distance}
onChange={(e) => update("distance", e.target.value)}
aria-invalid={!!errors.distance || undefined}
aria-describedby={describedBy("distance", `${ids.distance}-error`)}
data-testid="manual-workout-distance"
/>
<FieldError
id={`${ids.distance}-error`}
message={fieldError("distance")}
/>
</div>
<div className="space-y-2">
<Label htmlFor={ids.energy}>
{t("insights.workouts.manual.energyLabel")}
</Label>
<Input
id={ids.energy}
inputMode="decimal"
autoComplete="off"
placeholder={t("common.optional")}
value={draft.energyKcal}
onChange={(e) => update("energyKcal", e.target.value)}
aria-invalid={!!errors.energyKcal || undefined}
aria-describedby={describedBy("energyKcal", `${ids.energy}-error`)}
data-testid="manual-workout-energy"
/>
<FieldError
id={`${ids.energy}-error`}
message={fieldError("energyKcal")}
/>
</div>
</div>
{saveError && (
<div
id={ids.saveError}
role="alert"
aria-live="assertive"
className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm"
>
{saveError}
</div>
)}
{footerSlot ? createPortal(footerNode, footerSlot) : footerNode}
</form>
);
}
@@ -0,0 +1,94 @@
"use client";
import { useState } from "react";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { sheetBodyHasUnsavedInput } from "@/components/dashboard/quick-entry-sheets";
import { ManualWorkoutForm } from "@/components/workouts/manual-workout-form";
import { useTranslations } from "@/lib/i18n/context";
/**
* The "Log workout" sheet for a surface that owns its own open state (the
* workouts page header). The dashboard quick-add and the capture picker mount
* `ManualWorkoutForm` inside their own sheets, which already carry the same
* confirm-before-discard guard; this one carries it too, so a half-filled
* form is never dropped by a stray swipe.
*/
export function ManualWorkoutSheet({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { t } = useTranslations();
const [footerEl, setFooterEl] = useState<HTMLDivElement | null>(null);
const [confirmDiscardOpen, setConfirmDiscardOpen] = useState(false);
function handleOpenChange(next: boolean) {
if (next) return onOpenChange(true);
if (sheetBodyHasUnsavedInput()) {
setConfirmDiscardOpen(true);
return;
}
onOpenChange(false);
}
return (
<>
<ResponsiveSheet
open={open}
onOpenChange={handleOpenChange}
title={t("insights.workouts.manual.sheetTitle")}
description={t("insights.workouts.manual.sheetDescription")}
footer={<div ref={setFooterEl} className="flex w-full" />}
>
{open && (
<ManualWorkoutForm
onSuccess={() => onOpenChange(false)}
onCancel={() => onOpenChange(false)}
footerSlot={footerEl}
/>
)}
</ResponsiveSheet>
<AlertDialog
open={confirmDiscardOpen}
onOpenChange={setConfirmDiscardOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("dashboard.quickEntryDiscard.title")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("dashboard.quickEntryDiscard.description")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("dashboard.quickEntryDiscard.cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setConfirmDiscardOpen(false);
onOpenChange(false);
}}
>
{t("dashboard.quickEntryDiscard.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -255,6 +255,7 @@ describe("every surface that shows a module's data is owned by it", () => {
["overview:cycle-ring", "cycle"],
["capture:mood", "mood"],
["capture:medication", "medications"],
["capture:workout", "workouts"],
["trend:mood", "mood"],
["widget:medications", "medications"],
["correlation:MEDICATION_COMPLIANCE", "medications"],
+1
View File
@@ -123,6 +123,7 @@ const STATIC_SURFACE_MODULE = {
// ── Add menu ──
"capture:mood": "mood",
"capture:medication": "medications",
"capture:workout": "workouts",
// ── Dashboard widgets ──
// CORE widgets (weight, blood pressure, pulse, body fat, the vital-derived
+1
View File
@@ -357,6 +357,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"workout.batch.payload_too_large",
"workout.batch.source_not_permitted",
"workout.batch.too_large",
"workout.delete.synced_source",
],
"(unprefixed)": [
"about_me_conflict",
+30
View File
@@ -312,6 +312,36 @@ export const workoutPaths: NonNullable<ZodOpenApiObject["paths"]> = {
...stdResponses,
},
},
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.",
requestParams: { path: z.object({ id: z.string() }) },
responses: {
"200": {
description: "Deleted.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({ deleted: z.literal(true) }),
"DeleteWorkoutResponse",
),
},
},
},
"404": {
description: "Workout not found (or owned by another user).",
content: { "application/json": { schema: errorEnvelope } },
},
"409": {
description:
"The workout was synced from a device or service, not entered by hand.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
},
"/api/workouts/batch": {
post: {
@@ -0,0 +1,259 @@
import { describe, expect, it } from "vitest";
import {
buildManualWorkoutEntry,
canLogWorkout,
emptyManualWorkoutDraft,
newManualWorkoutExternalId,
wallClockNow,
type ManualWorkoutContext,
type ManualWorkoutDraft,
} from "@/lib/workouts/manual-entry";
import { createBatchWorkoutSchema } from "@/lib/validations/workout";
const NOW = new Date("2026-09-15T12:00:00Z");
const ID = "manual:5b0f3c1e-1111-4222-8333-444455556666";
const CTX: ManualWorkoutContext = {
timezone: "Europe/Berlin",
unitPreference: "metric",
externalId: ID,
now: NOW,
};
function draft(over: Partial<ManualWorkoutDraft> = {}): ManualWorkoutDraft {
return {
sportType: "running",
// 08:00 in Berlin (UTC+2 in September) = 06:00Z.
start: "2026-09-15T08:00",
hours: "0",
minutes: "45",
distance: "",
energyKcal: "",
...over,
};
}
function errorsOf(over: Partial<ManualWorkoutDraft>, ctx = CTX) {
const result = buildManualWorkoutEntry(draft(over), ctx);
if (result.ok) throw new Error("expected the draft to be refused");
return result.errors;
}
describe("buildManualWorkoutEntry — the payload", () => {
it("builds exactly the batch entry: MANUAL, the form's external id, start + duration", () => {
const result = buildManualWorkoutEntry(draft(), CTX);
expect(result).toEqual({
ok: true,
entry: {
sportType: "running",
startedAt: "2026-09-15T06:00:00.000Z",
endedAt: "2026-09-15T06:45:00.000Z",
source: "MANUAL",
externalId: ID,
},
});
});
it("sends the same external id on every submit of one form", () => {
const a = buildManualWorkoutEntry(draft(), CTX);
const b = buildManualWorkoutEntry(draft(), { ...CTX, now: new Date() });
expect(a.ok && b.ok).toBe(true);
if (a.ok && b.ok) expect(a.entry.externalId).toBe(b.entry.externalId);
});
it("is accepted by the batch route's own schema", () => {
const result = buildManualWorkoutEntry(
draft({ distance: "10,5", energyKcal: "640" }),
CTX,
);
if (!result.ok) throw new Error("expected ok");
const parsed = createBatchWorkoutSchema.safeParse({
workouts: [result.entry],
});
expect(parsed.success).toBe(true);
if (parsed.success) expect(parsed.data.workouts[0]!.source).toBe("MANUAL");
});
it("reads the start in the profile zone, not the device's", () => {
const result = buildManualWorkoutEntry(
draft({ start: "2026-09-15T05:00" }),
{
...CTX,
timezone: "America/New_York",
},
);
// 05:00 in New York (UTC-4 in September) = 09:00Z.
expect(result.ok && result.entry.startedAt).toBe(
"2026-09-15T09:00:00.000Z",
);
});
it("stores a metric distance in metres and energy as typed", () => {
const result = buildManualWorkoutEntry(
draft({ distance: "5,25", energyKcal: "312.5" }),
CTX,
);
expect(result.ok && result.entry.totalDistanceM).toBe(5250);
expect(result.ok && result.entry.totalEnergyKcal).toBe(312.5);
});
it("converts an imperial distance from miles to metres", () => {
const result = buildManualWorkoutEntry(draft({ distance: "3.1" }), {
...CTX,
unitPreference: "imperial",
});
// 3.1 mi = 4988.97 m, stored to a tenth.
expect(result.ok && result.entry.totalDistanceM).toBe(4989);
});
it("leaves distance and energy out when they were not entered", () => {
const result = buildManualWorkoutEntry(draft(), CTX);
expect(result.ok && "totalDistanceM" in result.entry).toBe(false);
expect(result.ok && "totalEnergyKcal" in result.entry).toBe(false);
});
it("accepts a session of exactly 24 hours", () => {
const result = buildManualWorkoutEntry(
draft({ start: "2026-09-14T08:00", hours: "24", minutes: "" }),
CTX,
);
expect(result.ok && result.entry.endedAt).toBe("2026-09-15T06:00:00.000Z");
});
});
describe("buildManualWorkoutEntry — validation", () => {
it("asks for a sport", () => {
expect(errorsOf({ sportType: "" }).sportType).toBe(
"insights.workouts.manual.errors.sportRequired",
);
});
it("refuses a start in the future", () => {
expect(errorsOf({ start: "2026-09-15T15:00" }).start).toBe(
"insights.workouts.manual.errors.startInFuture",
);
});
it("tolerates the minute the form was opened in", () => {
// 14:00:30 Berlin is 30 s past `now`: inside the slack.
const result = buildManualWorkoutEntry(
draft({ start: "2026-09-15T14:00" }),
{
...CTX,
now: new Date("2026-09-15T11:59:30Z"),
},
);
expect(result.ok).toBe(true);
});
it("asks for a start", () => {
expect(errorsOf({ start: "" }).start).toBe(
"insights.workouts.manual.errors.startRequired",
);
});
it("refuses a zero duration", () => {
expect(errorsOf({ hours: "", minutes: "" }).duration).toBe(
"insights.workouts.manual.errors.durationRequired",
);
expect(errorsOf({ hours: "0", minutes: "0" }).duration).toBe(
"insights.workouts.manual.errors.durationRequired",
);
});
it("refuses more than 24 hours", () => {
expect(errorsOf({ hours: "24", minutes: "1" }).duration).toBe(
"insights.workouts.manual.errors.durationTooLong",
);
});
it("refuses minutes past 59 and anything that is not a whole number", () => {
for (const over of [
{ minutes: "60" },
{ minutes: "-5" },
{ hours: "1.5" },
{ hours: "abc" },
]) {
expect(errorsOf(over).duration, JSON.stringify(over)).toBe(
"insights.workouts.manual.errors.durationInvalid",
);
}
});
it("refuses a negative, garbled or impossible distance", () => {
for (const distance of ["-1", "5km", "1000.1"]) {
expect(errorsOf({ distance }).distance, distance).toBe(
"insights.workouts.manual.errors.distanceInvalid",
);
}
// The ceiling is in metres, so it binds in miles too.
expect(
errorsOf({ distance: "622" }, { ...CTX, unitPreference: "imperial" })
.distance,
).toBe("insights.workouts.manual.errors.distanceInvalid");
});
it("accepts a zero distance", () => {
const result = buildManualWorkoutEntry(draft({ distance: "0" }), CTX);
expect(result.ok && result.entry.totalDistanceM).toBe(0);
});
it("refuses negative or implausible energy", () => {
for (const energyKcal of ["-10", "20001", "lots"]) {
expect(errorsOf({ energyKcal }).energyKcal, energyKcal).toBe(
"insights.workouts.manual.errors.energyInvalid",
);
}
});
it("reports every refused field at once", () => {
const errors = errorsOf({ sportType: "", minutes: "", hours: "" });
expect(Object.keys(errors).sort()).toEqual(["duration", "sportType"]);
});
});
describe("the form's defaults", () => {
it("opens on the current minute in the profile zone", () => {
expect(wallClockNow(NOW, "Europe/Berlin")).toBe("2026-09-15T14:00");
expect(emptyManualWorkoutDraft(NOW, "Asia/Seoul")).toMatchObject({
sportType: "",
start: "2026-09-15T21:00",
hours: "",
minutes: "",
});
});
it("mints a distinct, stable-shaped external id per form", () => {
const a = newManualWorkoutExternalId();
const b = newManualWorkoutExternalId();
expect(a).toMatch(/^manual:[0-9a-f-]{36}$/);
expect(a).not.toBe(b);
});
});
describe("canLogWorkout — who is offered the entry", () => {
const OWN = { inSharedRecord: false };
const SHARED = { inSharedRecord: true };
it("offers it in one's own record with the workouts module on", () => {
expect(canLogWorkout(OWN, { workouts: true })).toBe(true);
});
it("offers it while the module map is still loading", () => {
expect(canLogWorkout(OWN, undefined)).toBe(true);
expect(canLogWorkout(OWN, null)).toBe(true);
});
it("withholds it when the workouts module is off", () => {
expect(canLogWorkout(OWN, { workouts: false })).toBe(false);
});
it("does not follow any other module", () => {
expect(canLogWorkout(OWN, { mood: false, recovery: false })).toBe(true);
});
it("withholds it inside somebody else's record", () => {
expect(canLogWorkout(SHARED, { workouts: true })).toBe(false);
});
});
+316
View File
@@ -0,0 +1,316 @@
/**
* A workout entered by hand: the form's draft, its validation, and the one
* batch entry it becomes.
*
* HealthLog records workouts; it does not plan them. So the hand-entered
* shape is the session as a health record sees it and nothing more: what,
* when, how long, and optionally how far and how much energy. No sets, no
* exercises, no heart rate typed in from memory.
*
* The entry is written through `POST /api/workouts/batch`, the same route the
* phone syncs through, as `source: "MANUAL"` with an `externalId` minted once
* per opened form. That id is what makes a double submit (a second tap, a
* retried request) a `duplicate` instead of a second row: the route's
* `(userId, source, externalId)` key catches it.
*
* Pure, no React and no fetch, so the rules are pinned without a browser.
* Error values are i18n keys; the form translates them.
*/
import { z } from "zod/v4";
import {
getQuantityTransform,
invertDisplayTransform,
type UnitPreference,
} from "@/lib/measurements/display-transform";
import { wallClockInTz, zonedWallClockToUtc } from "@/lib/tz/wall-clock";
import {
workoutSportTypeEnum,
type WorkoutSportType,
} from "@/lib/validations/workout";
import { randomId } from "@/lib/random-id";
import { isSurfaceVisible, type SurfaceModuleMap } from "@/lib/modules/surface";
/** One session is at most a day long. */
export const MANUAL_WORKOUT_MAX_DURATION_SEC = 24 * 60 * 60;
/** The batch schema's own ceiling: 1000 km. */
export const MANUAL_WORKOUT_MAX_DISTANCE_M = 1_000_000;
/** A day of hard endurance work stays well under this. */
export const MANUAL_WORKOUT_MAX_ENERGY_KCAL = 20_000;
/**
* Slack on "not in the future". The form's default start is the minute the
* sheet opened, and the clock keeps moving while the person types.
*/
const FUTURE_TOLERANCE_MS = 60_000;
export interface ManualWorkoutDraft {
/** Empty until the person picks one. */
sportType: WorkoutSportType | "";
/** Wall clock in the profile timezone, `yyyy-MM-ddTHH:mm`. */
start: string;
hours: string;
minutes: string;
/** In the reader's unit (km or mi). Empty means not recorded. */
distance: string;
/** Kilocalories. Empty means not recorded. */
energyKcal: string;
}
export type ManualWorkoutField =
"sportType" | "start" | "duration" | "distance" | "energyKcal";
/** The one entry of the `POST /api/workouts/batch` body this form sends. */
export interface ManualWorkoutEntry {
sportType: WorkoutSportType;
startedAt: string;
endedAt: string;
source: "MANUAL";
externalId: string;
totalDistanceM?: number;
totalEnergyKcal?: number;
}
export interface ManualWorkoutContext {
/** The profile timezone the start is read in. */
timezone: string;
unitPreference: UnitPreference;
/** The id this form instance sends with every submit. */
externalId: string;
now: Date;
}
export type ManualWorkoutResult =
| { ok: true; entry: ManualWorkoutEntry }
| { ok: false; errors: Partial<Record<ManualWorkoutField, string>> };
const ERR = "insights.workouts.manual.errors";
/**
* Whether "Log workout" is offered: in one's own record, with the workouts
* module on.
*
* Own record only because the route behind it is: the batch ingest resolves
* the caller (`requireAuth`), and a delegate acting on somebody else's record
* would be refused, so no section of a grant can admit it. The module half is
* the `capture:workout` surface; an absent module map (the account still
* loading) offers it, the gate's default-on contract.
*
* The workouts page asks this; the dashboard add menu and the capture picker
* ask `visibleCaptureKinds`, which applies the same two terms to the
* `workout` kind.
*/
export function canLogWorkout(
caps: { inSharedRecord: boolean },
modules?: SurfaceModuleMap | null,
): boolean {
return !caps.inSharedRecord && isSurfaceVisible("capture:workout", modules);
}
/** A fresh id for one opened form. The prefix says where the row came from. */
export function newManualWorkoutExternalId(): string {
return `manual:${randomId()}`;
}
/** `now` as the wall clock the start field shows, `yyyy-MM-ddTHH:mm`. */
export function wallClockNow(now: Date, timezone: string): string {
const p = wallClockInTz(now, timezone);
const pad = (n: number) => String(n).padStart(2, "0");
return `${p.year}-${pad(p.month)}-${pad(p.day)}T${pad(p.hour)}:${pad(p.minute)}`;
}
export function emptyManualWorkoutDraft(
now: Date,
timezone: string,
): ManualWorkoutDraft {
return {
sportType: "",
start: wallClockNow(now, timezone),
hours: "",
minutes: "",
distance: "",
energyKcal: "",
};
}
const WALL_CLOCK = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/;
function startToUtc(start: string, timezone: string): Date | null {
const m = WALL_CLOCK.exec(start);
if (!m) return null;
const [year, month, day, hour, minute] = m.slice(1).map(Number) as [
number,
number,
number,
number,
number,
];
if (month < 1 || month > 12 || day < 1 || day > 31) return null;
if (hour > 23 || minute > 59) return null;
const at = zonedWallClockToUtc({ year, month, day, hour, minute }, timezone);
return Number.isNaN(at.getTime()) ? null : at;
}
/**
* A typed number, or `null` for an empty field, or `NaN` for garbage.
* Accepts a decimal comma, which is what half the locales type.
*/
function parseDecimal(raw: string): number | null {
const trimmed = raw.trim();
if (trimmed === "") return null;
if (!/^\d+([.,]\d+)?$/.test(trimmed)) return Number.NaN;
return Number(trimmed.replace(",", "."));
}
function parseWhole(raw: string): number | null {
const trimmed = raw.trim();
if (trimmed === "") return null;
if (!/^\d+$/.test(trimmed)) return Number.NaN;
return Number(trimmed);
}
/**
* The draft, checked. Built per call because "not in the future" and the
* unit conversion depend on the context.
*/
function manualWorkoutSchema(ctx: ManualWorkoutContext) {
return z
.object({
sportType: z.string(),
start: z.string(),
hours: z.string(),
minutes: z.string(),
distance: z.string(),
energyKcal: z.string(),
})
.superRefine((d, issue) => {
if (!workoutSportTypeEnum.safeParse(d.sportType).success) {
issue.addIssue({
code: "custom",
path: ["sportType"],
message: `${ERR}.sportRequired`,
});
}
const startedAt = startToUtc(d.start, ctx.timezone);
if (startedAt === null) {
issue.addIssue({
code: "custom",
path: ["start"],
message: `${ERR}.startRequired`,
});
} else if (
startedAt.getTime() >
ctx.now.getTime() + FUTURE_TOLERANCE_MS
) {
issue.addIssue({
code: "custom",
path: ["start"],
message: `${ERR}.startInFuture`,
});
}
const hours = parseWhole(d.hours);
const minutes = parseWhole(d.minutes);
if (
Number.isNaN(hours) ||
Number.isNaN(minutes) ||
(minutes !== null && minutes > 59)
) {
issue.addIssue({
code: "custom",
path: ["duration"],
message: `${ERR}.durationInvalid`,
});
} else {
const sec = ((hours ?? 0) * 60 + (minutes ?? 0)) * 60;
if (sec <= 0) {
issue.addIssue({
code: "custom",
path: ["duration"],
message: `${ERR}.durationRequired`,
});
} else if (sec > MANUAL_WORKOUT_MAX_DURATION_SEC) {
issue.addIssue({
code: "custom",
path: ["duration"],
message: `${ERR}.durationTooLong`,
});
}
}
const distance = parseDecimal(d.distance);
if (
distance !== null &&
(Number.isNaN(distance) ||
toMetres(distance, ctx.unitPreference) >
MANUAL_WORKOUT_MAX_DISTANCE_M)
) {
issue.addIssue({
code: "custom",
path: ["distance"],
message: `${ERR}.distanceInvalid`,
});
}
const energy = parseDecimal(d.energyKcal);
if (
energy !== null &&
(Number.isNaN(energy) || energy > MANUAL_WORKOUT_MAX_ENERGY_KCAL)
) {
issue.addIssue({
code: "custom",
path: ["energyKcal"],
message: `${ERR}.energyInvalid`,
});
}
});
}
/** A distance in the reader's unit, in metres to a tenth. */
function toMetres(display: number, preference: UnitPreference): number {
const metres = invertDisplayTransform(
display,
getQuantityTransform("distance", preference),
);
return Math.round(metres * 10) / 10;
}
/**
* Validate the draft and build the batch entry from it, field by field.
* Returns the first error per field when it does not validate.
*/
export function buildManualWorkoutEntry(
draft: ManualWorkoutDraft,
ctx: ManualWorkoutContext,
): ManualWorkoutResult {
const parsed = manualWorkoutSchema(ctx).safeParse(draft);
if (!parsed.success) {
const errors: Partial<Record<ManualWorkoutField, string>> = {};
for (const issue of parsed.error.issues) {
const field = issue.path[0] as ManualWorkoutField;
errors[field] ??= issue.message;
}
return { ok: false, errors };
}
// Every branch below was proven by the schema above.
const startedAt = startToUtc(draft.start, ctx.timezone)!;
const durationSec =
((parseWhole(draft.hours) ?? 0) * 60 + (parseWhole(draft.minutes) ?? 0)) *
60;
const distance = parseDecimal(draft.distance);
const energy = parseDecimal(draft.energyKcal);
const entry: ManualWorkoutEntry = {
sportType: draft.sportType as WorkoutSportType,
startedAt: startedAt.toISOString(),
endedAt: new Date(startedAt.getTime() + durationSec * 1000).toISOString(),
source: "MANUAL",
externalId: ctx.externalId,
};
if (distance !== null) {
entry.totalDistanceM = toMetres(distance, ctx.unitPreference);
}
if (energy !== null) entry.totalEnergyKcal = energy;
return { ok: true, entry };
}
@@ -0,0 +1,200 @@
/**
* A workout entered by hand, end to end against a real Postgres.
*
* The web form builds its entry with `buildManualWorkoutEntry` and posts it
* to `POST /api/workouts/batch`; this suite drives that exact entry through
* the real route and reads it back through `GET /api/workouts`, then deletes
* it through `DELETE /api/workouts/{id}`. Pins:
*
* - the entry lands as `MANUAL`, with the start, duration and metres the
* form computed, and the list serves it;
* - a second submit of the same form is a `duplicate`, not a second row;
* - delete removes a hand-entered workout and refuses a synced one.
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
process.env.ENCRYPTION_KEY =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const TEST_USER_ID = "user-workout-manual-entry";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
const { POST: postBatch } = await import("@/app/api/workouts/batch/route");
const { GET: listWorkouts } = await import("@/app/api/workouts/route");
const { DELETE: deleteWorkout } = await import("@/app/api/workouts/[id]/route");
const { buildManualWorkoutEntry, newManualWorkoutExternalId } =
await import("@/lib/workouts/manual-entry");
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
await getPrismaClient().user.create({
data: {
id: TEST_USER_ID,
username: "workout-manual",
email: "workout-manual@example.test",
},
});
const session = await getPrismaClient().session.create({
data: {
userId: TEST_USER_ID,
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
},
});
cookieJar.set("healthlog_session", session.id);
});
function post(body: unknown): Promise<Response> {
return postBatch(
new NextRequest("http://localhost/api/workouts/batch", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}),
);
}
function list(): Promise<Response> {
return listWorkouts(new NextRequest("http://localhost/api/workouts"));
}
function del(id: string): Promise<Response> {
return deleteWorkout(
new NextRequest(`http://localhost/api/workouts/${id}`, {
method: "DELETE",
}),
{ params: Promise.resolve({ id }) },
);
}
/** The entry the form sends for a 45-minute imperial run, yesterday 07:30 Berlin. */
function formEntry(externalId: string) {
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000);
const day = yesterday.toISOString().slice(0, 10);
const built = buildManualWorkoutEntry(
{
sportType: "running",
start: `${day}T07:30`,
hours: "0",
minutes: "45",
distance: "5",
energyKcal: "410",
},
{
timezone: "Europe/Berlin",
unitPreference: "imperial",
externalId,
now: new Date(),
},
);
if (!built.ok) throw new Error(JSON.stringify(built.errors));
return built.entry;
}
describe("a workout entered by hand", () => {
it("lands through the batch route as MANUAL and is served by the list", async () => {
const entry = formEntry(newManualWorkoutExternalId());
const res = await post({ workouts: [entry] });
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.entries).toEqual([{ index: 0, status: "inserted" }]);
const listRes = await list();
expect(listRes.status).toBe(200);
const { data } = await listRes.json();
expect(data.workouts).toHaveLength(1);
const row = data.workouts[0];
expect(row).toMatchObject({
sportType: "running",
startedAt: entry.startedAt,
endedAt: entry.endedAt,
durationSec: 45 * 60,
// 5 mi, stored in metres to a tenth.
distanceM: 8046.7,
activeEnergyKcal: 410,
source: "MANUAL",
externalId: entry.externalId,
});
});
it("stores a second submit of the same form once", async () => {
const entry = formEntry(newManualWorkoutExternalId());
await post({ workouts: [entry] });
const again = await post({ workouts: [entry] });
expect((await again.json()).data.entries).toEqual([
{ index: 0, status: "duplicate" },
]);
expect(
await getPrismaClient().workout.count({
where: { userId: TEST_USER_ID },
}),
).toBe(1);
});
it("can be deleted, and is gone from the list", async () => {
await post({ workouts: [formEntry(newManualWorkoutExternalId())] });
const stored = await getPrismaClient().workout.findFirstOrThrow({
where: { userId: TEST_USER_ID },
});
const res = await del(stored.id);
expect(res.status).toBe(200);
expect((await res.json()).data).toEqual({ deleted: true });
const { data } = await (await list()).json();
expect(data.workouts).toHaveLength(0);
});
it("refuses to delete a synced workout and leaves it in place", async () => {
const synced = await getPrismaClient().workout.create({
data: {
userId: TEST_USER_ID,
sportType: "cycling",
startedAt: new Date(Date.now() - 2 * 60 * 60 * 1000),
endedAt: new Date(Date.now() - 60 * 60 * 1000),
durationSec: 3600,
source: "APPLE_HEALTH",
externalId: "hk-sync-1",
},
});
const res = await del(synced.id);
expect(res.status).toBe(409);
expect(
await getPrismaClient().workout.count({ where: { id: synced.id } }),
).toBe(1);
});
});