feat(workouts): log a workout by hand from the workouts page

A small sheet with sport, start, duration and optionally distance and
active energy, nothing else. The start is read in the profile timezone,
distance in the reader's unit and stored in metres. It saves through
POST /api/workouts/batch as source MANUAL with an external id minted once
per opened form, so a second tap lands as a duplicate, not a second row.

The workouts page carries the action in its header and in the empty
state; the detail page offers delete for a hand-entered workout.

Refs #1085
This commit is contained in:
Marc-André Bombeck committed 2026-10-02 21:22:56 +02:00
1 parent 5e6fd327a5
commit e0fc879d72
16 files changed
+1721 -16

No files matched your search

+33 -2
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",
@@ -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",
+33 -2
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",
@@ -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",
+33 -2
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",
@@ -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",
+33 -2
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",
@@ -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",
+33 -2
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",
@@ -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",
+33 -2
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": "복용 기록",
@@ -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": "혈당",
+33 -2
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",
@@ -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",
+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?"
/>
) : (
@@ -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
@@ -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 { toast } from "sonner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
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);
toast.success(t("insights.workouts.manual.deleted"));
setOpen(false);
router.replace("/insights/workouts");
} catch {
toast.error(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,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<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);
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 = (
<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>
</>
);
}
@@ -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);
});
});
+315
View File
@@ -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<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 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<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 };
}