diff --git a/messages/de.json b/messages/de.json index 66cc4a50a..6a70427ba 100644 --- a/messages/de.json +++ b/messages/de.json @@ -403,7 +403,9 @@ "medication": "Medikament", "medicationDescription": "Eine Dosis als genommen markieren", "mood": "Stimmung", - "moodDescription": "Stimmung und Tags erfassen" + "moodDescription": "Stimmung und Tags erfassen", + "workout": "Workout", + "workoutDescription": "Eine Einheit, die kein Gerät aufgezeichnet hat" }, "cycle": "Zyklus", "labs": "Laborwerte", @@ -520,6 +522,7 @@ "quickAddMeasurement": "Messung erfassen", "quickAddMood": "Stimmung erfassen", "quickAddMedicationIntake": "Einnahme erfassen", + "quickAddWorkout": "Workout erfassen", "customizeDashboard": "Dashboard anpassen", "medicationIntakeQuickAdd": { "sheetTitle": "Medikamenteneinnahme erfassen", @@ -3436,7 +3439,35 @@ "countFew": "{count} Workouts", "filterLabel": "Nach Sportart filtern", "filterAll": "Alle Sportarten", - "filterEmpty": "Keine Einheiten dieser Sportart in deinem Verlauf." + "filterEmpty": "Keine Einheiten dieser Sportart in deinem Verlauf.", + "manual": { + "logWorkout": "Workout erfassen", + "sheetTitle": "Workout erfassen", + "sheetDescription": "Trag eine Einheit von Hand ein: was, wann und wie lange.", + "sportLabel": "Aktivität", + "sportPlaceholder": "Aktivität wählen", + "startLabel": "Beginn", + "durationLabel": "Dauer", + "distanceLabel": "Distanz ({unit})", + "energyLabel": "Aktive Energie (kcal)", + "alreadySaved": "Dieses Workout ist schon gespeichert", + "saveError": "Das Workout konnte nicht gespeichert werden", + "deleteLabel": "Workout löschen", + "deleteConfirmTitle": "Dieses Workout löschen?", + "deleteConfirmDescription": "Dieses von Hand eingetragene Workout wird endgültig entfernt.", + "deleted": "Workout gelöscht", + "deleteError": "Das Workout konnte nicht gelöscht werden", + "errors": { + "sportRequired": "Wähle eine Aktivität.", + "startRequired": "Gib Datum und Uhrzeit ein.", + "startInFuture": "Der Beginn darf nicht in der Zukunft liegen.", + "durationRequired": "Gib an, wie lange das Workout gedauert hat.", + "durationInvalid": "Gib ganze Stunden und Minuten von 0 bis 59 ein.", + "durationTooLong": "Ein Workout dauert höchstens 24 Stunden.", + "distanceInvalid": "Gib die Distanz als Zahl in einem realistischen Bereich ein.", + "energyInvalid": "Gib die aktive Energie als Zahl bis 20.000 kcal ein." + } + } }, "bloodGlucose": { "title": "Blutzucker", diff --git a/messages/en.json b/messages/en.json index 8bfe26d62..4732ebbf8 100644 --- a/messages/en.json +++ b/messages/en.json @@ -403,7 +403,9 @@ "medication": "Medication", "medicationDescription": "Mark a dose as taken", "mood": "Mood", - "moodDescription": "Log your mood and tags" + "moodDescription": "Log your mood and tags", + "workout": "Workout", + "workoutDescription": "A session your devices did not record" }, "cycle": "Cycle", "labs": "Labs", @@ -520,6 +522,7 @@ "quickAddMeasurement": "Log measurement", "quickAddMood": "Log mood", "quickAddMedicationIntake": "Log medication intake", + "quickAddWorkout": "Log workout", "customizeDashboard": "Customize dashboard", "medicationIntakeQuickAdd": { "sheetTitle": "Log medication intake", @@ -3436,7 +3439,35 @@ "countFew": "{count} workouts", "filterLabel": "Filter by sport", "filterAll": "All sports", - "filterEmpty": "No sessions of this sport in your history." + "filterEmpty": "No sessions of this sport in your history.", + "manual": { + "logWorkout": "Log workout", + "sheetTitle": "Log workout", + "sheetDescription": "Enter a session by hand: what you did, when, and for how long.", + "sportLabel": "Activity", + "sportPlaceholder": "Choose an activity", + "startLabel": "Start", + "durationLabel": "Duration", + "distanceLabel": "Distance ({unit})", + "energyLabel": "Active energy (kcal)", + "alreadySaved": "This workout is already saved", + "saveError": "The workout could not be saved", + "deleteLabel": "Delete workout", + "deleteConfirmTitle": "Delete this workout?", + "deleteConfirmDescription": "This workout you entered by hand will be removed for good.", + "deleted": "Workout deleted", + "deleteError": "The workout could not be deleted", + "errors": { + "sportRequired": "Choose an activity.", + "startRequired": "Enter a date and time.", + "startInFuture": "The start cannot be in the future.", + "durationRequired": "Enter how long the workout lasted.", + "durationInvalid": "Enter whole hours, and minutes from 0 to 59.", + "durationTooLong": "A workout can last at most 24 hours.", + "distanceInvalid": "Enter the distance as a number within a realistic range.", + "energyInvalid": "Enter active energy as a number up to 20,000 kcal." + } + } }, "bloodGlucose": { "title": "Blood Glucose", diff --git a/messages/es.json b/messages/es.json index a3e109389..e2acfc13d 100644 --- a/messages/es.json +++ b/messages/es.json @@ -403,7 +403,9 @@ "medication": "Medicamento", "medicationDescription": "Marcar una dosis como tomada", "mood": "Estado de ánimo", - "moodDescription": "Registra tu estado de ánimo y etiquetas" + "moodDescription": "Registra tu estado de ánimo y etiquetas", + "workout": "Entrenamiento", + "workoutDescription": "Una sesión que tus dispositivos no registraron" }, "cycle": "Ciclo", "labs": "Análisis", @@ -520,6 +522,7 @@ "quickAddMeasurement": "Registrar medición", "quickAddMood": "Registrar estado de ánimo", "quickAddMedicationIntake": "Registrar toma", + "quickAddWorkout": "Registrar entrenamiento", "customizeDashboard": "Personalizar panel", "medicationIntakeQuickAdd": { "sheetTitle": "Registrar toma de medicación", @@ -3436,7 +3439,35 @@ "countFew": "{count} entrenamientos", "filterLabel": "Filtrar por deporte", "filterAll": "Todos los deportes", - "filterEmpty": "No hay sesiones de este deporte en tu historial." + "filterEmpty": "No hay sesiones de este deporte en tu historial.", + "manual": { + "logWorkout": "Registrar entrenamiento", + "sheetTitle": "Registrar entrenamiento", + "sheetDescription": "Introduce una sesión a mano: qué hiciste, cuándo y durante cuánto tiempo.", + "sportLabel": "Actividad", + "sportPlaceholder": "Elige una actividad", + "startLabel": "Inicio", + "durationLabel": "Duración", + "distanceLabel": "Distancia ({unit})", + "energyLabel": "Energía activa (kcal)", + "alreadySaved": "Este entrenamiento ya está guardado", + "saveError": "No se pudo guardar el entrenamiento", + "deleteLabel": "Eliminar entrenamiento", + "deleteConfirmTitle": "¿Eliminar este entrenamiento?", + "deleteConfirmDescription": "Este entrenamiento que introdujiste a mano se eliminará de forma definitiva.", + "deleted": "Entrenamiento eliminado", + "deleteError": "No se pudo eliminar el entrenamiento", + "errors": { + "sportRequired": "Elige una actividad.", + "startRequired": "Introduce una fecha y una hora.", + "startInFuture": "El inicio no puede estar en el futuro.", + "durationRequired": "Indica cuánto duró el entrenamiento.", + "durationInvalid": "Introduce horas enteras y minutos de 0 a 59.", + "durationTooLong": "Un entrenamiento puede durar como máximo 24 horas.", + "distanceInvalid": "Introduce la distancia como un número dentro de un rango realista.", + "energyInvalid": "Introduce la energía activa como un número de hasta 20.000 kcal." + } + } }, "bloodGlucose": { "title": "Glucosa en sangre", diff --git a/messages/fr.json b/messages/fr.json index d9e6a5017..e1f5fedce 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -403,7 +403,9 @@ "medication": "Médicament", "medicationDescription": "Marquer une dose comme prise", "mood": "Humeur", - "moodDescription": "Enregistrez votre humeur et des étiquettes" + "moodDescription": "Enregistrez votre humeur et des étiquettes", + "workout": "Entraînement", + "workoutDescription": "Une séance que vos appareils n’ont pas enregistrée" }, "cycle": "Cycle", "labs": "Analyses", @@ -520,6 +522,7 @@ "quickAddMeasurement": "Saisir une mesure", "quickAddMood": "Saisir une humeur", "quickAddMedicationIntake": "Saisir une prise", + "quickAddWorkout": "Saisir un entraînement", "customizeDashboard": "Personnaliser le tableau de bord", "medicationIntakeQuickAdd": { "sheetTitle": "Saisir une prise de médicament", @@ -3436,7 +3439,35 @@ "countFew": "{count} séances", "filterLabel": "Filtrer par sport", "filterAll": "Tous les sports", - "filterEmpty": "Aucune séance de ce sport dans votre historique." + "filterEmpty": "Aucune séance de ce sport dans votre historique.", + "manual": { + "logWorkout": "Saisir un entraînement", + "sheetTitle": "Saisir un entraînement", + "sheetDescription": "Saisissez une séance à la main : ce que vous avez fait, quand et combien de temps.", + "sportLabel": "Activité", + "sportPlaceholder": "Choisir une activité", + "startLabel": "Début", + "durationLabel": "Durée", + "distanceLabel": "Distance ({unit})", + "energyLabel": "Énergie active (kcal)", + "alreadySaved": "Cet entraînement est déjà enregistré", + "saveError": "L’entraînement n’a pas pu être enregistré", + "deleteLabel": "Supprimer l’entraînement", + "deleteConfirmTitle": "Supprimer cet entraînement ?", + "deleteConfirmDescription": "Cet entraînement saisi à la main sera supprimé définitivement.", + "deleted": "Entraînement supprimé", + "deleteError": "L’entraînement n’a pas pu être supprimé", + "errors": { + "sportRequired": "Choisissez une activité.", + "startRequired": "Saisissez une date et une heure.", + "startInFuture": "Le début ne peut pas être dans le futur.", + "durationRequired": "Indiquez la durée de l’entraînement.", + "durationInvalid": "Saisissez des heures entières et des minutes de 0 à 59.", + "durationTooLong": "Un entraînement dure au maximum 24 heures.", + "distanceInvalid": "Saisissez la distance sous forme de nombre, dans une plage réaliste.", + "energyInvalid": "Saisissez l’énergie active sous forme de nombre, jusqu’à 20 000 kcal." + } + } }, "bloodGlucose": { "title": "Glycémie", diff --git a/messages/it.json b/messages/it.json index 639c0a7a6..8786c7020 100644 --- a/messages/it.json +++ b/messages/it.json @@ -403,7 +403,9 @@ "medication": "Farmaco", "medicationDescription": "Segna una dose come assunta", "mood": "Umore", - "moodDescription": "Registra umore e tag" + "moodDescription": "Registra umore e tag", + "workout": "Allenamento", + "workoutDescription": "Una sessione che i tuoi dispositivi non hanno registrato" }, "cycle": "Ciclo", "labs": "Esami", @@ -520,6 +522,7 @@ "quickAddMeasurement": "Registra misurazione", "quickAddMood": "Registra umore", "quickAddMedicationIntake": "Registra assunzione", + "quickAddWorkout": "Registra allenamento", "customizeDashboard": "Personalizza dashboard", "medicationIntakeQuickAdd": { "sheetTitle": "Registra assunzione del farmaco", @@ -3436,7 +3439,35 @@ "countFew": "{count} allenamenti", "filterLabel": "Filtra per sport", "filterAll": "Tutti gli sport", - "filterEmpty": "Nessuna sessione di questo sport nella tua cronologia." + "filterEmpty": "Nessuna sessione di questo sport nella tua cronologia.", + "manual": { + "logWorkout": "Registra allenamento", + "sheetTitle": "Registra allenamento", + "sheetDescription": "Inserisci una sessione a mano: cosa hai fatto, quando e per quanto tempo.", + "sportLabel": "Attività", + "sportPlaceholder": "Scegli un’attività", + "startLabel": "Inizio", + "durationLabel": "Durata", + "distanceLabel": "Distanza ({unit})", + "energyLabel": "Energia attiva (kcal)", + "alreadySaved": "Questo allenamento è già salvato", + "saveError": "Non è stato possibile salvare l’allenamento", + "deleteLabel": "Elimina allenamento", + "deleteConfirmTitle": "Eliminare questo allenamento?", + "deleteConfirmDescription": "Questo allenamento inserito a mano verrà rimosso definitivamente.", + "deleted": "Allenamento eliminato", + "deleteError": "Non è stato possibile eliminare l’allenamento", + "errors": { + "sportRequired": "Scegli un’attività.", + "startRequired": "Inserisci data e ora.", + "startInFuture": "L’inizio non può essere nel futuro.", + "durationRequired": "Indica quanto è durato l’allenamento.", + "durationInvalid": "Inserisci ore intere e minuti da 0 a 59.", + "durationTooLong": "Un allenamento può durare al massimo 24 ore.", + "distanceInvalid": "Inserisci la distanza come numero in un intervallo realistico.", + "energyInvalid": "Inserisci l’energia attiva come numero fino a 20.000 kcal." + } + } }, "bloodGlucose": { "title": "Glicemia", diff --git a/messages/ko.json b/messages/ko.json index bab8a4d71..32087a7d4 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -403,7 +403,9 @@ "medication": "약", "medicationDescription": "복용으로 표시하기", "mood": "기분", - "moodDescription": "기분과 태그 기록하기" + "moodDescription": "기분과 태그 기록하기", + "workout": "운동", + "workoutDescription": "기기가 기록하지 못한 운동" }, "cycle": "생리 주기", "labs": "검사", @@ -520,6 +522,7 @@ "quickAddMeasurement": "측정 기록", "quickAddMood": "기분 기록", "quickAddMedicationIntake": "복용 기록", + "quickAddWorkout": "운동 기록", "customizeDashboard": "대시보드 맞춤 설정", "medicationIntakeQuickAdd": { "sheetTitle": "복용 기록", @@ -3436,7 +3439,35 @@ "countFew": "운동 {count}개", "filterLabel": "종목별 필터", "filterAll": "전체 종목", - "filterEmpty": "기록에 이 종목의 세션이 없어요." + "filterEmpty": "기록에 이 종목의 세션이 없어요.", + "manual": { + "logWorkout": "운동 기록", + "sheetTitle": "운동 기록", + "sheetDescription": "무엇을, 언제, 얼마나 했는지 직접 입력하세요.", + "sportLabel": "활동", + "sportPlaceholder": "활동 선택", + "startLabel": "시작", + "durationLabel": "시간", + "distanceLabel": "거리({unit})", + "energyLabel": "활동 에너지(kcal)", + "alreadySaved": "이미 저장된 운동입니다", + "saveError": "운동을 저장하지 못했습니다", + "deleteLabel": "운동 삭제", + "deleteConfirmTitle": "이 운동을 삭제할까요?", + "deleteConfirmDescription": "직접 입력한 이 운동이 영구적으로 삭제됩니다.", + "deleted": "운동을 삭제했습니다", + "deleteError": "운동을 삭제하지 못했습니다", + "errors": { + "sportRequired": "활동을 선택하세요.", + "startRequired": "날짜와 시간을 입력하세요.", + "startInFuture": "시작 시간은 미래일 수 없습니다.", + "durationRequired": "운동한 시간을 입력하세요.", + "durationInvalid": "시간은 정수로, 분은 0에서 59 사이로 입력하세요.", + "durationTooLong": "운동은 최대 24시간까지 입력할 수 있습니다.", + "distanceInvalid": "거리를 현실적인 범위의 숫자로 입력하세요.", + "energyInvalid": "활동 에너지는 20,000kcal 이하의 숫자로 입력하세요." + } + } }, "bloodGlucose": { "title": "혈당", diff --git a/messages/pl.json b/messages/pl.json index 1ecc73cc5..dafd7d8c8 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -403,7 +403,9 @@ "medication": "Lek", "medicationDescription": "Oznacz dawkę jako przyjętą", "mood": "Nastrój", - "moodDescription": "Zapisz nastrój i tagi" + "moodDescription": "Zapisz nastrój i tagi", + "workout": "Trening", + "workoutDescription": "Sesja, której nie zapisały twoje urządzenia" }, "cycle": "Cykl", "labs": "Wyniki badań", @@ -520,6 +522,7 @@ "quickAddMeasurement": "Zarejestruj pomiar", "quickAddMood": "Zarejestruj nastrój", "quickAddMedicationIntake": "Zarejestruj przyjęcie", + "quickAddWorkout": "Zarejestruj trening", "customizeDashboard": "Dostosuj pulpit", "medicationIntakeQuickAdd": { "sheetTitle": "Zarejestruj przyjęcie leku", @@ -3436,7 +3439,35 @@ "countFew": "{count} treningi", "filterLabel": "Filtruj według sportu", "filterAll": "Wszystkie sporty", - "filterEmpty": "Brak treningów tego sportu w Twojej historii." + "filterEmpty": "Brak treningów tego sportu w Twojej historii.", + "manual": { + "logWorkout": "Zarejestruj trening", + "sheetTitle": "Zarejestruj trening", + "sheetDescription": "Wpisz sesję ręcznie: rodzaj aktywności, początek i czas trwania.", + "sportLabel": "Aktywność", + "sportPlaceholder": "Wybierz aktywność", + "startLabel": "Początek", + "durationLabel": "Czas trwania", + "distanceLabel": "Dystans ({unit})", + "energyLabel": "Energia czynna (kcal)", + "alreadySaved": "Ten trening jest już zapisany", + "saveError": "Nie udało się zapisać treningu", + "deleteLabel": "Usuń trening", + "deleteConfirmTitle": "Usunąć ten trening?", + "deleteConfirmDescription": "Ten ręcznie wpisany trening zostanie trwale usunięty.", + "deleted": "Trening usunięty", + "deleteError": "Nie udało się usunąć treningu", + "errors": { + "sportRequired": "Wybierz aktywność.", + "startRequired": "Podaj datę i godzinę.", + "startInFuture": "Początek nie może być w przyszłości.", + "durationRequired": "Podaj, jak długo trwał trening.", + "durationInvalid": "Podaj pełne godziny i minuty od 0 do 59.", + "durationTooLong": "Trening może trwać najwyżej 24 godziny.", + "distanceInvalid": "Podaj dystans jako liczbę w realistycznym zakresie.", + "energyInvalid": "Podaj energię czynną jako liczbę do 20 000 kcal." + } + } }, "bloodGlucose": { "title": "Poziom glukozy", diff --git a/src/app/insights/workouts/[id]/page.tsx b/src/app/insights/workouts/[id]/page.tsx index 3ce3a7cf9..4d8d66d64 100644 --- a/src/app/insights/workouts/[id]/page.tsx +++ b/src/app/insights/workouts/[id]/page.tsx @@ -10,6 +10,7 @@ import { SubPageShell } from "@/components/insights/sub-page-shell"; import { Skeleton } from "@/components/ui/skeleton"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import { canonicalWorkoutDetailHref } from "@/lib/workouts/canonical-detail-route"; +import { DeleteWorkoutButton } from "@/components/workouts/delete-workout-button"; import { WorkoutDetailHeader, WorkoutDetailStats, @@ -64,6 +65,11 @@ export default function InsightsWorkoutDetailPage({ // No description. The one this page used to carry belongs to the LIST // ("Recent runs, rides, walks…, deduped…") and describes a surface // this is not — a detail page must not claim to be the list. + // A workout entered by hand can be deleted here; the control renders + // nothing for a synced one. + headerAction={ + data && !canonicalHref ? : null + } backLink={ (undefined); + const [logOpen, setLogOpen] = useState(false); + const { user } = useAuth(); + const capabilities = useRecordCapabilities(); + const showLog = canLogWorkout(capabilities, user?.modules); const { workouts, total, @@ -80,7 +89,31 @@ export default function InsightsWorkoutsPageClient() { description={t("insights.workouts.description")} explainerMetric="workouts" coachLaunch + headerAction={ + showLog ? ( + // Same 40 px ghost icon and widened hit area as the header + // cluster's other actions; it opens the form in place. + + ) : null + } > + {showLog ? ( + + ) : null} {/* No `` here: workouts are session records, not a MeasurementType series, so the period-over-period range read has nothing to aggregate. */} @@ -100,7 +133,13 @@ export default function InsightsWorkoutsPageClient() { icon={} title={t("insights.workouts.emptyState.title")} description={t("insights.workouts.emptyState.description")} - cta={null} + cta={ + showLog ? ( + + ) : null + } coachPrefill="I haven't logged any workouts yet — why does tracking them matter, and what should I focus on first?" /> ) : ( diff --git a/src/components/insights/sub-page-shell.tsx b/src/components/insights/sub-page-shell.tsx index 8170bbf00..744c764d2 100644 --- a/src/components/insights/sub-page-shell.tsx +++ b/src/components/insights/sub-page-shell.tsx @@ -151,6 +151,13 @@ export interface SubPageShellProps { * at the page foot. */ showAllValuesType?: string; + /** + * A page-owned action that opens something in place rather than linking + * away (the workouts page's "Log workout"). Leads the header cluster, in + * the same 40 px ghost-icon shape as its siblings; the page owns the + * control and whatever it opens. + */ + headerAction?: ReactNode; children: ReactNode; } @@ -171,6 +178,7 @@ export function SubPageShell({ coachLaunch = false, captureType, showAllValuesType, + headerAction, children, }: SubPageShellProps) { const { t } = useTranslations(); @@ -309,6 +317,7 @@ export function SubPageShell({ with a tighter gap the later sibling's invisible halo sat on top of its neighbour's clickable edge. */}
+ {headerAction} {captureHref ? ( + + + + + {t("insights.workouts.manual.deleteConfirmTitle")} + + + {t("insights.workouts.manual.deleteConfirmDescription")} + + + + + {t("common.cancel")} + + + {deleting ? ( + + ) : null} + {t("common.delete")} + + + + + + ); +} diff --git a/src/components/workouts/manual-workout-form.tsx b/src/components/workouts/manual-workout-form.tsx new file mode 100644 index 000000000..d50f130f9 --- /dev/null +++ b/src/components/workouts/manual-workout-form.tsx @@ -0,0 +1,387 @@ +"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 { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +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 { + const result = await apiPost("/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(() => + emptyManualWorkoutDraft(new Date(), timezone), + ); + const [errors, setErrors] = useState< + Partial> + >({}); + const [saveError, setSaveError] = useState(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( + 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); + toast.success( + outcome === "duplicate" + ? t("insights.workouts.manual.alreadySaved") + : 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 = ( +
+ {onCancel && ( + + )} + +
+ ); + + return ( +
+
+ + + +
+ +
+ + 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" + /> + +
+ +
+ + {t("insights.workouts.manual.durationLabel")} + +
+
+ + update("hours", e.target.value)} + aria-invalid={!!errors.duration || undefined} + data-testid="manual-workout-hours" + /> +
+
+ + update("minutes", e.target.value)} + aria-invalid={!!errors.duration || undefined} + data-testid="manual-workout-minutes" + /> +
+
+ +
+ +
+
+ + update("distance", e.target.value)} + aria-invalid={!!errors.distance || undefined} + aria-describedby={describedBy("distance", `${ids.distance}-error`)} + data-testid="manual-workout-distance" + /> + +
+
+ + update("energyKcal", e.target.value)} + aria-invalid={!!errors.energyKcal || undefined} + aria-describedby={describedBy("energyKcal", `${ids.energy}-error`)} + data-testid="manual-workout-energy" + /> + +
+
+ + {saveError && ( + + )} + + {footerSlot ? createPortal(footerNode, footerSlot) : footerNode} +
+ ); +} diff --git a/src/components/workouts/manual-workout-sheet.tsx b/src/components/workouts/manual-workout-sheet.tsx new file mode 100644 index 000000000..47903471f --- /dev/null +++ b/src/components/workouts/manual-workout-sheet.tsx @@ -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(null); + const [confirmDiscardOpen, setConfirmDiscardOpen] = useState(false); + + function handleOpenChange(next: boolean) { + if (next) return onOpenChange(true); + if (sheetBodyHasUnsavedInput()) { + setConfirmDiscardOpen(true); + return; + } + onOpenChange(false); + } + + return ( + <> + } + > + {open && ( + onOpenChange(false)} + onCancel={() => onOpenChange(false)} + footerSlot={footerEl} + /> + )} + + + + + + {t("dashboard.quickEntryDiscard.title")} + + + {t("dashboard.quickEntryDiscard.description")} + + + + + {t("dashboard.quickEntryDiscard.cancel")} + + { + setConfirmDiscardOpen(false); + onOpenChange(false); + }} + > + {t("dashboard.quickEntryDiscard.confirm")} + + + + + + ); +} diff --git a/src/lib/workouts/__tests__/manual-entry.test.ts b/src/lib/workouts/__tests__/manual-entry.test.ts new file mode 100644 index 000000000..7c3300475 --- /dev/null +++ b/src/lib/workouts/__tests__/manual-entry.test.ts @@ -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 { + 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, 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); + }); +}); diff --git a/src/lib/workouts/manual-entry.ts b/src/lib/workouts/manual-entry.ts new file mode 100644 index 000000000..5246eb6a2 --- /dev/null +++ b/src/lib/workouts/manual-entry.ts @@ -0,0 +1,315 @@ +/** + * 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> }; + +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 dashboard add menu, the capture picker and the workouts page all ask + * this one question. + */ +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> = {}; + 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 }; +}