From 5e6fd327a55ce52df257c67609202859957145c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 2 Oct 2026 21:22:56 +0200 Subject: [PATCH 01/11] feat(workouts): delete a workout entered by hand DELETE /api/workouts/{id} removes a MANUAL workout outright, with its child rows and the personal records keyed on it, then queues a silent detection pass so the previous best comes back. Any other source answers 409: the next sync would write it back. Own record only, 404 for another user's row. Refs #1085 --- docs/api/openapi.yaml | 60 ++++++ src/app/api/workouts/[id]/route.ts | 110 +++++++++- src/app/api/workouts/__tests__/delete.test.ts | 189 ++++++++++++++++++ src/lib/openapi/routes/workouts.ts | 30 +++ 4 files changed, 388 insertions(+), 1 deletion(-) create mode 100644 src/app/api/workouts/__tests__/delete.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 4a1bd8893..f0055fcf0 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -4661,6 +4661,42 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 + delete: + tags: + - Measurements + summary: Delete a workout entered by hand + description: "Removes a `MANUAL` workout outright, with its child rows, and any personal record that workout set; a + silent detection pass then re-derives the previous best. A workout from any other source answers 409 + (`workout.delete.synced_source`), because the next sync would write it back. Own record only: a cookie session + or a wildcard token. Another user's row answers 404." + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: Deleted. + content: + application/json: + schema: + $ref: "#/components/schemas/DeleteWorkoutResponse" + "401": *a1 + "404": + description: Workout not found (or owned by another user). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "409": + description: The workout was synced from a device or service, not entered by hand. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 /api/workouts/batch: post: tags: @@ -43051,6 +43087,30 @@ components: description: "One AI capability, resolved for one record from every layer that can say no. Render from it; never recompute it. Data never depends on it: measurements, scores, statistics and device records load whatever it says." + DeleteWorkoutResponse: + type: object + properties: + data: + type: object + properties: + deleted: + type: boolean + const: true + required: + - deleted + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false BatchWorkoutsResponse: type: object properties: diff --git a/src/app/api/workouts/[id]/route.ts b/src/app/api/workouts/[id]/route.ts index f7a7350b9..21f04d8a5 100644 --- a/src/app/api/workouts/[id]/route.ts +++ b/src/app/api/workouts/[id]/route.ts @@ -21,13 +21,21 @@ * route geometry); absent for manual entries. * - Ownership-gated: a row owned by another user resolves as 404 so * the existence channel never leaks. + * + * `DELETE /api/workouts/{id}` removes a workout entered by hand. Only a + * `MANUAL` row: a synced workout would come back with the next sync, so + * deleting it here would be a promise the record cannot keep. See the + * handler below. */ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiError, apiSuccess } from "@/lib/api-response"; +import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; +import { enqueuePrDetection } from "@/lib/jobs/pr-detection"; import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate"; @@ -357,3 +365,103 @@ export const GET = apiHandler( }); }, ); + +/** + * `DELETE /api/workouts/{id}` — remove a workout that was entered by hand. + * + * - Own record only: `requireAuth()` with no scope admits a cookie session or + * a wildcard token and refuses an acting-account carrier, the same arm the + * detail read above uses. A narrow token is refused. + * - Ownership: another user's row answers 404, like the read. + * - `MANUAL` only. Every other source is a sync, and the next sync would + * write the row back, so it answers 409 instead of pretending. + * - Not module-gated: deleting is cleanup of the person's own rows, which + * the data layer allows whatever the module says + * (`module-route-gate-inventory.test.ts`). + * + * The workout table carries no tombstone, so the row is removed outright; + * route, samples, insight and claim rows cascade with it. A personal record + * the workout set would otherwise outlive it (the detector only ever raises + * a stored best), so the records keyed on this workout go in the same + * transaction and a silent detection pass re-derives the honest best. + */ +export const DELETE = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const { id } = await params; + + const row = await prisma.workout.findUnique({ + where: { id }, + select: { + id: true, + userId: true, + source: true, + externalId: true, + startedAt: true, + sportType: true, + }, + }); + if (!row || row.userId !== user.id) { + return apiError("Workout not found", 404); + } + if (row.source !== "MANUAL") { + annotate({ + action: { + name: "workout.delete", + entity_type: "workout", + entity_id: id, + }, + meta: { outcome: "synced_source", source: row.source }, + }); + return apiError( + "Only a workout entered by hand can be deleted here", + 409, + { errorCode: "workout.delete.synced_source" }, + ); + } + + const removedRecords = await prisma.$transaction(async (tx) => { + const records = await tx.personalRecord.deleteMany({ + where: { + userId: user.id, + metricSlot: { not: null }, + source: "MANUAL", + ...(row.externalId !== null + ? { externalId: row.externalId } + : { externalId: null, achievedAt: row.startedAt }), + }, + }); + await tx.workout.delete({ where: { id: row.id } }); + return records.count; + }); + + await auditLog("workout.delete", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + workoutId: id, + sportType: row.sportType, + personalRecordsRemoved: removedRecords, + }, + }); + annotate({ + action: { name: "workout.delete", entity_type: "workout", entity_id: id }, + meta: { outcome: "deleted", personalRecordsRemoved: removedRecords }, + }); + + invalidateUserMeasurements(user.id, { evict: true }); + + if (removedRecords > 0) { + try { + await enqueuePrDetection(user.id, { silent: true }); + } catch (err) { + annotate({ + action: { name: "personal_records.detection_enqueue_failed" }, + meta: { error: err instanceof Error ? err.message : String(err) }, + }); + } + } + + return apiSuccess({ deleted: true }); + }, +); diff --git a/src/app/api/workouts/__tests__/delete.test.ts b/src/app/api/workouts/__tests__/delete.test.ts new file mode 100644 index 000000000..340fd2cee --- /dev/null +++ b/src/app/api/workouts/__tests__/delete.test.ts @@ -0,0 +1,189 @@ +/** + * Unit suite for `DELETE /api/workouts/{id}`. + * + * Pins: + * - a workout entered by hand is removed, with the personal records keyed + * on it, and a silent detection pass re-derives the previous best; + * - a synced workout answers 409 and nothing is touched, because the next + * sync would write it back; + * - another user's row and a missing row answer 404; + * - no session answers 401. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const tx = { + personalRecord: { deleteMany: vi.fn() }, + workout: { delete: vi.fn() }, +}; + +vi.mock("@/lib/db", () => ({ + prisma: { + workout: { findUnique: vi.fn() }, + $transaction: vi.fn(async (fn: (client: typeof tx) => unknown) => fn(tx)), + }, +})); +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserMeasurements: vi.fn(), +})); +vi.mock("@/lib/jobs/pr-detection", () => ({ + enqueuePrDetection: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { DELETE } from "../[id]/route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { auditLog } from "@/lib/auth/audit"; +import { enqueuePrDetection } from "@/lib/jobs/pr-detection"; +import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "testuser", role: "USER" as const }, +}; + +const MANUAL_ROW = { + id: "w-1", + userId: "user-1", + source: "MANUAL" as const, + externalId: "manual:5b0f3c1e-1111-4222-8333-444455556666", + startedAt: new Date("2026-09-01T07:00:00Z"), + sportType: "running", +}; + +function del(id = "w-1") { + return DELETE( + new NextRequest(`http://localhost/api/workouts/${id}`, { + method: "DELETE", + }), + { params: Promise.resolve({ id }) }, + ); +} + +describe("DELETE /api/workouts/{id}", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + tx.personalRecord.deleteMany.mockResolvedValue({ count: 0 }); + tx.workout.delete.mockResolvedValue({ id: "w-1" }); + }); + + it("removes a workout entered by hand", async () => { + vi.mocked(prisma.workout.findUnique).mockResolvedValue(MANUAL_ROW as never); + + const res = await del(); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ data: { deleted: true }, error: null }); + expect(tx.workout.delete).toHaveBeenCalledWith({ where: { id: "w-1" } }); + expect(auditLog).toHaveBeenCalledWith( + "workout.delete", + expect.objectContaining({ userId: "user-1" }), + ); + expect(invalidateUserMeasurements).toHaveBeenCalledWith("user-1", { + evict: true, + }); + }); + + it("takes the personal records the workout set with it and re-derives the best", async () => { + vi.mocked(prisma.workout.findUnique).mockResolvedValue(MANUAL_ROW as never); + tx.personalRecord.deleteMany.mockResolvedValue({ count: 2 }); + + const res = await del(); + + expect(res.status).toBe(200); + expect(tx.personalRecord.deleteMany).toHaveBeenCalledWith({ + where: { + userId: "user-1", + metricSlot: { not: null }, + source: "MANUAL", + externalId: MANUAL_ROW.externalId, + }, + }); + expect(enqueuePrDetection).toHaveBeenCalledWith("user-1", { + silent: true, + }); + }); + + it("keys a record without an external id on the session's start", async () => { + vi.mocked(prisma.workout.findUnique).mockResolvedValue({ + ...MANUAL_ROW, + externalId: null, + } as never); + + await del(); + + expect(tx.personalRecord.deleteMany).toHaveBeenCalledWith({ + where: { + userId: "user-1", + metricSlot: { not: null }, + source: "MANUAL", + externalId: null, + achievedAt: MANUAL_ROW.startedAt, + }, + }); + // Nothing was removed, so nothing needs re-deriving. + expect(enqueuePrDetection).not.toHaveBeenCalled(); + }); + + it.each(["APPLE_HEALTH", "WITHINGS", "WHOOP", "EXTERNAL"])( + "refuses a %s workout with 409 and touches nothing", + async (source) => { + vi.mocked(prisma.workout.findUnique).mockResolvedValue({ + ...MANUAL_ROW, + source, + } as never); + + const res = await del(); + + expect(res.status).toBe(409); + const body = await res.json(); + expect(body.data).toBeNull(); + expect(prisma.$transaction).not.toHaveBeenCalled(); + expect(tx.workout.delete).not.toHaveBeenCalled(); + expect(auditLog).not.toHaveBeenCalled(); + }, + ); + + it("answers 404 for another user's workout", async () => { + vi.mocked(prisma.workout.findUnique).mockResolvedValue({ + ...MANUAL_ROW, + userId: "someone-else", + } as never); + + const res = await del(); + + expect(res.status).toBe(404); + expect(prisma.$transaction).not.toHaveBeenCalled(); + }); + + it("answers 404 when there is no such workout", async () => { + vi.mocked(prisma.workout.findUnique).mockResolvedValue(null); + + const res = await del("missing"); + + expect(res.status).toBe(404); + }); + + it("answers 401 without a session", async () => { + vi.mocked(getSession).mockResolvedValue(null); + + const res = await del(); + + expect(res.status).toBe(401); + expect(prisma.workout.findUnique).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/openapi/routes/workouts.ts b/src/lib/openapi/routes/workouts.ts index 2ef30d3bf..e8bda4fe9 100644 --- a/src/lib/openapi/routes/workouts.ts +++ b/src/lib/openapi/routes/workouts.ts @@ -312,6 +312,36 @@ export const workoutPaths: NonNullable = { ...stdResponses, }, }, + delete: { + tags: ["Measurements"], + summary: "Delete a workout entered by hand", + description: + "Removes a `MANUAL` workout outright, with its child rows, and any personal record that workout set; a silent detection pass then re-derives the previous best. A workout from any other source answers 409 (`workout.delete.synced_source`), because the next sync would write it back. Own record only: a cookie session or a wildcard token. Another user's row answers 404.", + requestParams: { path: z.object({ id: z.string() }) }, + responses: { + "200": { + description: "Deleted.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ deleted: z.literal(true) }), + "DeleteWorkoutResponse", + ), + }, + }, + }, + "404": { + description: "Workout not found (or owned by another user).", + content: { "application/json": { schema: errorEnvelope } }, + }, + "409": { + description: + "The workout was synced from a device or service, not entered by hand.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, }, "/api/workouts/batch": { post: { From e0fc879d72d668a50c4ff4fd220b857d9c02d6b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 2 Oct 2026 21:22:56 +0200 Subject: [PATCH 02/11] 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 --- messages/de.json | 35 +- messages/en.json | 35 +- messages/es.json | 35 +- messages/fr.json | 35 +- messages/it.json | 35 +- messages/ko.json | 35 +- messages/pl.json | 35 +- src/app/insights/workouts/[id]/page.tsx | 6 + src/app/insights/workouts/page-client.tsx | 43 +- src/components/insights/sub-page-shell.tsx | 9 + .../__tests__/manual-workout-form.test.tsx | 241 +++++++++++ .../workouts/delete-workout-button.tsx | 138 +++++++ .../workouts/manual-workout-form.tsx | 387 ++++++++++++++++++ .../workouts/manual-workout-sheet.tsx | 94 +++++ .../workouts/__tests__/manual-entry.test.ts | 259 ++++++++++++ src/lib/workouts/manual-entry.ts | 315 ++++++++++++++ 16 files changed, 1721 insertions(+), 16 deletions(-) create mode 100644 src/components/workouts/__tests__/manual-workout-form.test.tsx create mode 100644 src/components/workouts/delete-workout-button.tsx create mode 100644 src/components/workouts/manual-workout-form.tsx create mode 100644 src/components/workouts/manual-workout-sheet.tsx create mode 100644 src/lib/workouts/__tests__/manual-entry.test.ts create mode 100644 src/lib/workouts/manual-entry.ts 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 }; +} From 715268c62b24810c63f15d0d25ee5eb0c8839198 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 2 Oct 2026 21:23:01 +0200 Subject: [PATCH 03/11] feat(workouts): offer Log workout in the add menu and the capture picker The dashboard add menu and the bottom bar's capture picker gain a workout entry next to measurement, mood and medication. It belongs to the workouts module (capture:workout) and is offered in one's own record only, because the batch ingest resolves the caller and no grant reaches it. Refs #1085 --- .../delegated-write-affordances.test.tsx | 47 +++++++++++++++++-- .../__tests__/dashboard-header.test.tsx | 36 +++++++++++++- src/components/dashboard/dashboard-header.tsx | 17 +++++-- .../dashboard/quick-entry-sheets.tsx | 40 ++++++++++++++-- src/components/layout/capture-picker.tsx | 41 ++++++++++++---- .../__tests__/surface-module-registry.test.ts | 1 + src/lib/modules/surface.ts | 1 + 7 files changed, 160 insertions(+), 23 deletions(-) diff --git a/src/components/__tests__/delegated-write-affordances.test.tsx b/src/components/__tests__/delegated-write-affordances.test.tsx index 1dd578381..f8159ff8c 100644 --- a/src/components/__tests__/delegated-write-affordances.test.tsx +++ b/src/components/__tests__/delegated-write-affordances.test.tsx @@ -126,7 +126,11 @@ function writeCaps( sections: ShareDomain[] | null, ) { const writable = new Set(delegatedDomains(level, sections, "write")); - return { canWriteDomain: (domain: ShareDomain) => writable.has(domain) }; + return { + // A delegate is always inside somebody else's record. + inSharedRecord: true, + canWriteDomain: (domain: ShareDomain) => writable.has(domain), + }; } const mockAccessRef: { value: AccountAccess } = { value: OWN_RECORD }; @@ -499,7 +503,7 @@ describe("the capture picker's kinds", () => { /** The picker asks one question per kind, of that kind's own section. */ const caps = writeCaps; - const OWNER_CAPS = { canWriteDomain: () => true }; + const OWNER_CAPS = { inSharedRecord: false, canWriteDomain: () => true }; const WRITER_CAPS = caps("write", null); const READER_CAPS = caps("read", null); // A guardian: every section with a delegated route answers at MANAGE. @@ -558,6 +562,29 @@ describe("the capture picker's kinds", () => { ); }); + it("offers a workout in the caller's own record, gated by the workouts module", () => { + expect(visibleCaptureKinds(OWNER_CAPS, ["workout"])).toEqual(["workout"]); + expect( + visibleCaptureKinds(OWNER_CAPS, ["workout"], { workouts: false }), + ).toEqual([]); + expect( + visibleCaptureKinds(OWNER_CAPS, ["workout"], { mood: false }), + ).toEqual(["workout"]); + }); + + it("never offers a delegate a workout, at any level or scope", () => { + // The batch ingest resolves the caller (`requireAuth`), so no grant + // reaches it: a measurements-scoped MANAGE grant is refused the same way. + for (const delegate of [ + WRITER_CAPS, + READER_CAPS, + GUARDIAN_CAPS, + caps("manage", ["measurements"]), + ]) { + expect(visibleCaptureKinds(delegate, ["workout"])).toEqual([]); + } + }); + it("offers a scoped MANAGE delegate the mood entry only with the mind section", () => { expect(visibleCaptureKinds(caps("manage", ["mind"]), [...ALL])).toEqual([ "mood", @@ -659,6 +686,20 @@ describe("a form opened before the record answered", () => { ); }); + it("withdraws the workout sheet in any shared record, and keeps it in one's own", () => { + for (const level of ["read", "write", "manage"] as const) { + expect(admittedQuickEntry("workout", writeCaps(level, null)), level).toBe( + null, + ); + } + expect( + admittedQuickEntry("workout", { + inSharedRecord: false, + canWriteDomain: () => true, + }), + ).toBe("workout"); + }); + it("keeps the mood sheet for a guardian, whose mind routes answer at MANAGE", () => { expect(admittedQuickEntry("mood", writeCaps("manage", null))).toBe("mood"); }); @@ -678,7 +719,7 @@ describe("a form opened before the record answered", () => { }); it("leaves the owner's own sheets alone", () => { - const OWNER_CAPS = { canWriteDomain: () => true }; + const OWNER_CAPS = { inSharedRecord: false, canWriteDomain: () => true }; for (const sheet of ["measurement", "mood", "medicationIntake"] as const) { expect(admittedQuickEntry(sheet, OWNER_CAPS), sheet).toBe(sheet); } diff --git a/src/components/dashboard/__tests__/dashboard-header.test.tsx b/src/components/dashboard/__tests__/dashboard-header.test.tsx index 4f7ae8422..5ac00b99d 100644 --- a/src/components/dashboard/__tests__/dashboard-header.test.tsx +++ b/src/components/dashboard/__tests__/dashboard-header.test.tsx @@ -11,7 +11,7 @@ * post-hydration name personalisation); * 2. the SSR pass renders the name-less fallback (hydration-safe). */ -import { describe, it, expect, vi } from "vitest"; +import { beforeEach, describe, it, expect, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; import { I18nProvider } from "@/lib/i18n/context"; @@ -30,9 +30,16 @@ vi.mock("@/components/ui/dropdown-menu", () => ({ <>{children} ), })); +const modulesRef: { value: Record | undefined } = { + value: undefined, +}; vi.mock("@/hooks/use-auth", () => ({ useAuth: () => ({ - user: { username: "tester", timezone: "Europe/Berlin" }, + user: { + username: "tester", + timezone: "Europe/Berlin", + modules: modulesRef.value, + }, }), })); @@ -85,3 +92,28 @@ describe(" — quick-add menu", () => { expect(html).not.toContain("Log water"); }); }); + +describe(" — Log workout", () => { + beforeEach(() => { + modulesRef.value = undefined; + }); + + it("offers it beside the other capture entries", () => { + modulesRef.value = { workouts: true }; + const html = renderSSR( + undefined} />, + "en", + ); + expect(html).toContain("Log workout"); + }); + + it("withholds it when the workouts module is off, and keeps the rest", () => { + modulesRef.value = { workouts: false }; + const html = renderSSR( + undefined} />, + "en", + ); + expect(html).not.toContain("Log workout"); + expect(html).toContain("Log measurement"); + }); +}); diff --git a/src/components/dashboard/dashboard-header.tsx b/src/components/dashboard/dashboard-header.tsx index 1e0b554cb..cb2842708 100644 --- a/src/components/dashboard/dashboard-header.tsx +++ b/src/components/dashboard/dashboard-header.tsx @@ -12,7 +12,7 @@ */ import { useMemo } from "react"; import Link from "next/link"; -import { Activity, Pill, Plus, Waves, Wrench } from "lucide-react"; +import { Activity, Footprints, Pill, Plus, Waves, Wrench } from "lucide-react"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -26,6 +26,7 @@ import { useAuth } from "@/hooks/use-auth"; import { useMounted } from "@/hooks/use-mounted"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { hourInTz } from "@/lib/tz/format"; +import { canLogWorkout } from "@/lib/workouts/manual-entry"; import type { QuickEntryDialog } from "@/components/dashboard/quick-entry-sheets"; export function DashboardHeader({ @@ -43,12 +44,16 @@ export function DashboardHeader({ // grant that opens neither of the two sections the menu never asked about. // The customize shortcut points at a settings page sharing does not cover at // all. - const { canWriteDomain, inSharedRecord } = useRecordCapabilities(); + const capabilities = useRecordCapabilities(); + const { canWriteDomain, inSharedRecord } = capabilities; const canAddMeasurement = canWriteDomain("measurements"); const canAddIntake = canWriteDomain("medications"); // A mood entry is created at MANAGE under the mind section. const canAddMood = canWriteDomain("mind"); - const canAddAnything = canAddMeasurement || canAddIntake || canAddMood; + // A workout: own record only, workouts module on (`canLogWorkout`). + const canAddWorkout = canLogWorkout(capabilities, user?.modules); + const canAddAnything = + canAddMeasurement || canAddIntake || canAddMood || canAddWorkout; // The pre-hero greeting derivation, kept hydration-safe: `user` comes // from the auth query, which can resolve before this boundary @@ -183,6 +188,12 @@ export function DashboardHeader({ {t("dashboard.quickAddMedicationIntake")} )} + {canAddWorkout && ( + onQuickEntry("workout")}> + + )} )} diff --git a/src/components/dashboard/quick-entry-sheets.tsx b/src/components/dashboard/quick-entry-sheets.tsx index 189958163..cd294a11a 100644 --- a/src/components/dashboard/quick-entry-sheets.tsx +++ b/src/components/dashboard/quick-entry-sheets.tsx @@ -24,6 +24,7 @@ import { import { MeasurementForm } from "@/components/measurements/measurement-form"; import { MoodForm } from "@/components/mood/mood-form"; import { MedicationIntakeQuickAdd } from "@/components/dashboard/medication-intake-quick-add"; +import { ManualWorkoutForm } from "@/components/workouts/manual-workout-form"; import { useTranslations } from "@/lib/i18n/context"; import { useRecordCapabilities, @@ -32,15 +33,20 @@ import { import type { ShareDomain } from "@/lib/sharing/scope"; export type QuickEntryDialog = - "measurement" | "mood" | "medicationIntake" | null; + "measurement" | "mood" | "medicationIntake" | "workout" | null; -/** The section each sheet writes to; see the capture picker. */ +/** + * The section each sheet writes to; see the capture picker. `null` marks a + * sheet whose route resolves the caller rather than the record, which no + * grant reaches: a workout goes through the batch ingest, own record only. + */ const QUICK_ENTRY_DOMAIN: Readonly< - Record, ShareDomain> + Record, ShareDomain | null> > = { measurement: "measurements", medicationIntake: "medications", mood: "mind", + workout: null, }; /** @@ -59,10 +65,13 @@ const QUICK_ENTRY_DOMAIN: Readonly< */ export function admittedQuickEntry( open: QuickEntryDialog, - caps: Pick, + caps: Pick, ): QuickEntryDialog { if (open === null) return open; - return caps.canWriteDomain(QUICK_ENTRY_DOMAIN[open]) ? open : null; + const domain = QUICK_ENTRY_DOMAIN[open]; + const admitted = + domain === null ? !caps.inSharedRecord : caps.canWriteDomain(domain); + return admitted ? open : null; } /** @@ -151,6 +160,9 @@ export function QuickEntrySheets({ // mobile soft keyboard. const [medicationIntakeFooterEl, setMedicationIntakeFooterEl] = useState(null); + const [workoutFooterEl, setWorkoutFooterEl] = useState( + null, + ); // v1.11.3 F3 — when an open quick-entry sheet is dismissed with // unsaved input, hold the close in this flag and surface a confirm // instead of nulling the dialog outright. Cleared once the user @@ -214,6 +226,24 @@ export function QuickEntrySheets({ /> + {/* Log a workout by hand. Mounted only while open, so every opening + is a fresh form with its own external id. */} + } + > + {openSheet === "workout" && ( + + )} + + {/* v1.11.3 F3 — confirm before discarding a partly-filled quick-entry form when the sheet is dismissed by an overlay tap, Escape, or a mobile swipe-down. Keeps the sheet open diff --git a/src/components/layout/capture-picker.tsx b/src/components/layout/capture-picker.tsx index 068dd5f46..daddc6ef1 100644 --- a/src/components/layout/capture-picker.tsx +++ b/src/components/layout/capture-picker.tsx @@ -1,11 +1,12 @@ "use client"; import { useState } from "react"; -import { Activity, Pill, Waves } from "lucide-react"; +import { Activity, Footprints, Pill, Waves } from "lucide-react"; import { MeasurementForm } from "@/components/measurements/measurement-form"; import { MoodForm } from "@/components/mood/mood-form"; import { MedicationIntakeQuickAdd } from "@/components/dashboard/medication-intake-quick-add"; +import { ManualWorkoutForm } from "@/components/workouts/manual-workout-form"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { AlertDialog, @@ -30,11 +31,12 @@ import { /** * The center "Log" capture action (iOS parity — the bottom bar's * middle slot is a capture CTA, not a destination). It opens a small - * picker that routes to three existing quick-entry surfaces: + * picker that routes to four existing quick-entry surfaces: * * - Measurement → `` (the `/measurements` add form) * - Medication → `` (dashboard quick-add) * - Mood → `` (the `/mood` add form, 5-face flow) + * - Workout → `` (the workouts page's "Log workout") * * The picker reuses each existing capture component rather than * rebuilding parallel forms. @@ -50,7 +52,7 @@ import { * instead of closing unconditionally. */ -type CaptureKind = "measurement" | "medication" | "mood"; +type CaptureKind = "measurement" | "medication" | "mood" | "workout"; /** * The section each capture surface writes to. @@ -66,10 +68,13 @@ type CaptureKind = "measurement" | "medication" | "mood"; * so asking it per section answers both cases and the mood entry's * MANAGE-only create as well. */ -const CAPTURE_KIND_DOMAIN: Readonly> = { +const CAPTURE_KIND_DOMAIN: Readonly> = { measurement: "measurements", medication: "medications", mood: "mind", + // A workout goes through the batch ingest, which resolves the caller: + // no grant reaches it, so it is offered in one's own record only. + workout: null, }; /** The order the chooser lists them in. */ @@ -77,6 +82,7 @@ export const CAPTURE_KIND_ORDER: ReadonlyArray = [ "measurement", "medication", "mood", + "workout", ]; /** @@ -118,15 +124,16 @@ export function admittedCaptureKind( * kind the record allows, the gate's default-on contract. */ export function visibleCaptureKinds( - caps: Pick, + caps: Pick, kinds: ReadonlyArray, modules?: SurfaceModuleMap | null, ): CaptureKind[] { - return kinds.filter( - (kind) => - caps.canWriteDomain(CAPTURE_KIND_DOMAIN[kind]) && - isSurfaceVisible(`capture:${kind}`, modules), - ); + return kinds.filter((kind) => { + const domain = CAPTURE_KIND_DOMAIN[kind]; + const writable = + domain === null ? !caps.inSharedRecord : caps.canWriteDomain(domain); + return writable && isSurfaceVisible(`capture:${kind}`, modules); + }); } interface CapturePickerProps { @@ -203,6 +210,12 @@ export function CapturePicker({ open, onOpenChange }: CapturePickerProps) { description: t("nav.capture.moodDescription"), icon: Waves, }, + { + kind: "workout", + label: t("nav.capture.workout"), + description: t("nav.capture.workoutDescription"), + icon: Footprints, + }, ]; const options = allOptions.filter((opt) => offered.includes(opt.kind)); @@ -210,6 +223,7 @@ export function CapturePicker({ open, onOpenChange }: CapturePickerProps) { measurement: t("measurements.addMeasurement"), medication: t("nav.capture.medication"), mood: t("mood.addEntry"), + workout: t("insights.workouts.manual.sheetTitle"), }; // `openKind === null` keeps the form sheet closed (the title is unread // then); the mood label is the harmless default, matching the prior @@ -272,6 +286,13 @@ export function CapturePicker({ open, onOpenChange }: CapturePickerProps) { footerSlot={footerEl} /> )} + {openKind === "workout" && ( + + )} {openKind === "mood" && ( { ["overview:cycle-ring", "cycle"], ["capture:mood", "mood"], ["capture:medication", "medications"], + ["capture:workout", "workouts"], ["trend:mood", "mood"], ["widget:medications", "medications"], ["correlation:MEDICATION_COMPLIANCE", "medications"], diff --git a/src/lib/modules/surface.ts b/src/lib/modules/surface.ts index efc803457..4e39ab30f 100644 --- a/src/lib/modules/surface.ts +++ b/src/lib/modules/surface.ts @@ -123,6 +123,7 @@ const STATIC_SURFACE_MODULE = { // ── Add menu ── "capture:mood": "mood", "capture:medication": "medications", + "capture:workout": "workouts", // ── Dashboard widgets ── // CORE widgets (weight, blood pressure, pulse, body fat, the vital-derived From 92ce6997925eded5d4db4ee24ff84cd7bf3cb96b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 2 Oct 2026 21:25:36 +0200 Subject: [PATCH 04/11] test(workouts): drive a hand-entered workout through the real routes Builds the entry the form sends, posts it through the batch route, reads it back from the list, re-posts it as a duplicate, and deletes it; a synced workout is refused. Refs #1085 --- .../integration/workout-manual-entry.test.ts | 201 ++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 tests/integration/workout-manual-entry.test.ts diff --git a/tests/integration/workout-manual-entry.test.ts b/tests/integration/workout-manual-entry.test.ts new file mode 100644 index 000000000..4ae50381f --- /dev/null +++ b/tests/integration/workout-manual-entry.test.ts @@ -0,0 +1,201 @@ +/** + * A workout entered by hand, end to end against a real Postgres. + * + * The web form builds its entry with `buildManualWorkoutEntry` and posts it + * to `POST /api/workouts/batch`; this suite drives that exact entry through + * the real route and reads it back through `GET /api/workouts`, then deletes + * it through `DELETE /api/workouts/{id}`. Pins: + * + * - the entry lands as `MANUAL`, with the start, duration and metres the + * form computed, and the list serves it; + * - a second submit of the same form is a `duplicate`, not a second row; + * - delete removes a hand-entered workout and refuses a synced one. + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +process.env.ENCRYPTION_KEY = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +const TEST_USER_ID = "user-workout-manual-entry"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const { POST: postBatch } = await import("@/app/api/workouts/batch/route"); +const { GET: listWorkouts } = await import("@/app/api/workouts/route"); +const { DELETE: deleteWorkout } = await import("@/app/api/workouts/[id]/route"); +const { buildManualWorkoutEntry, newManualWorkoutExternalId } = await import( + "@/lib/workouts/manual-entry" +); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + await getPrismaClient().user.create({ + data: { + id: TEST_USER_ID, + username: "workout-manual", + email: "workout-manual@example.test", + }, + }); + const session = await getPrismaClient().session.create({ + data: { + userId: TEST_USER_ID, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }, + }); + cookieJar.set("healthlog_session", session.id); +}); + +function post(body: unknown): Promise { + return postBatch( + new NextRequest("http://localhost/api/workouts/batch", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + ); +} + +function list(): Promise { + return listWorkouts(new NextRequest("http://localhost/api/workouts")); +} + +function del(id: string): Promise { + return deleteWorkout( + new NextRequest(`http://localhost/api/workouts/${id}`, { + method: "DELETE", + }), + { params: Promise.resolve({ id }) }, + ); +} + +/** The entry the form sends for a 45-minute imperial run, yesterday 07:30 Berlin. */ +function formEntry(externalId: string) { + const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000); + const day = yesterday.toISOString().slice(0, 10); + const built = buildManualWorkoutEntry( + { + sportType: "running", + start: `${day}T07:30`, + hours: "0", + minutes: "45", + distance: "5", + energyKcal: "410", + }, + { + timezone: "Europe/Berlin", + unitPreference: "imperial", + externalId, + now: new Date(), + }, + ); + if (!built.ok) throw new Error(JSON.stringify(built.errors)); + return built.entry; +} + +describe("a workout entered by hand", () => { + it("lands through the batch route as MANUAL and is served by the list", async () => { + const entry = formEntry(newManualWorkoutExternalId()); + + const res = await post({ workouts: [entry] }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.entries).toEqual([{ index: 0, status: "inserted" }]); + + const listRes = await list(); + expect(listRes.status).toBe(200); + const { data } = await listRes.json(); + expect(data.workouts).toHaveLength(1); + const row = data.workouts[0]; + expect(row).toMatchObject({ + sportType: "running", + startedAt: entry.startedAt, + endedAt: entry.endedAt, + durationSec: 45 * 60, + // 5 mi, stored in metres to a tenth. + distanceM: 8046.7, + activeEnergyKcal: 410, + source: "MANUAL", + externalId: entry.externalId, + }); + }); + + it("stores a second submit of the same form once", async () => { + const entry = formEntry(newManualWorkoutExternalId()); + + await post({ workouts: [entry] }); + const again = await post({ workouts: [entry] }); + + expect((await again.json()).data.entries).toEqual([ + { index: 0, status: "duplicate" }, + ]); + expect( + await getPrismaClient().workout.count({ + where: { userId: TEST_USER_ID }, + }), + ).toBe(1); + }); + + it("can be deleted, and is gone from the list", async () => { + await post({ workouts: [formEntry(newManualWorkoutExternalId())] }); + const stored = await getPrismaClient().workout.findFirstOrThrow({ + where: { userId: TEST_USER_ID }, + }); + + const res = await del(stored.id); + expect(res.status).toBe(200); + expect((await res.json()).data).toEqual({ deleted: true }); + + const { data } = await (await list()).json(); + expect(data.workouts).toHaveLength(0); + }); + + it("refuses to delete a synced workout and leaves it in place", async () => { + const synced = await getPrismaClient().workout.create({ + data: { + userId: TEST_USER_ID, + sportType: "cycling", + startedAt: new Date(Date.now() - 2 * 60 * 60 * 1000), + endedAt: new Date(Date.now() - 60 * 60 * 1000), + durationSec: 3600, + source: "APPLE_HEALTH", + externalId: "hk-sync-1", + }, + }); + + const res = await del(synced.id); + + expect(res.status).toBe(409); + expect( + await getPrismaClient().workout.count({ where: { id: synced.id } }), + ).toBe(1); + }); +}); From cfbb57ab3e21b4a3c3c4f9defded7c664d349b41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 2 Oct 2026 21:28:07 +0200 Subject: [PATCH 05/11] test(workouts): format the manual-entry integration suite Refs #1085 --- tests/integration/workout-manual-entry.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/integration/workout-manual-entry.test.ts b/tests/integration/workout-manual-entry.test.ts index 4ae50381f..b02b03f71 100644 --- a/tests/integration/workout-manual-entry.test.ts +++ b/tests/integration/workout-manual-entry.test.ts @@ -50,9 +50,8 @@ vi.mock("@/lib/db-compat", () => ({ const { POST: postBatch } = await import("@/app/api/workouts/batch/route"); const { GET: listWorkouts } = await import("@/app/api/workouts/route"); const { DELETE: deleteWorkout } = await import("@/app/api/workouts/[id]/route"); -const { buildManualWorkoutEntry, newManualWorkoutExternalId } = await import( - "@/lib/workouts/manual-entry" -); +const { buildManualWorkoutEntry, newManualWorkoutExternalId } = + await import("@/lib/workouts/manual-entry"); beforeEach(async () => { await truncateAllTables(getPrismaClient()); From 6eb79a1d47b3c330e03fe3f14f3447bf4373cd5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 2 Oct 2026 21:31:25 +0200 Subject: [PATCH 06/11] fix(workouts): report manual saves through the outcome module and register the delete A repeated submit that wrote nothing reads as a neutral notice rather than a success. The delete control is entered in the destructive-control registry, and its 409 code in the published error catalogue. Refs #1085 --- src/__tests__/destructive-control-guard.test.ts | 7 +++++++ .../workouts/delete-workout-button.tsx | 6 +++--- src/components/workouts/manual-workout-form.tsx | 17 +++++++++++------ src/lib/openapi/error-codes.ts | 1 + 4 files changed, 22 insertions(+), 9 deletions(-) diff --git a/src/__tests__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts index fcf209cfe..49b4bf75f 100644 --- a/src/__tests__/destructive-control-guard.test.ts +++ b/src/__tests__/destructive-control-guard.test.ts @@ -380,6 +380,13 @@ const REGISTRY: DestructiveEntry[] = [ recovery: "tombstoned-no-restore", confirm: ["AlertDialog"], }, + { + file: "components/workouts/delete-workout-button.tsx", + destroys: + "one workout entered by hand, with the personal records it set; synced workouts are refused by the route", + recovery: "permanent", + confirm: ["AlertDialog"], + }, // ── Visits and the address book ───────────────────────────────────────── { diff --git a/src/components/workouts/delete-workout-button.tsx b/src/components/workouts/delete-workout-button.tsx index bc6e84b19..904ea6280 100644 --- a/src/components/workouts/delete-workout-button.tsx +++ b/src/components/workouts/delete-workout-button.tsx @@ -4,7 +4,6 @@ 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, @@ -17,6 +16,7 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; +import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { apiDelete } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; @@ -78,11 +78,11 @@ export function DeleteWorkoutButton({ setDeleting(true); try { await deleteManualWorkout(workout.id, queryClient); - toast.success(t("insights.workouts.manual.deleted")); + toastWrittenOutcome("success", t("insights.workouts.manual.deleted")); setOpen(false); router.replace("/insights/workouts"); } catch { - toast.error(t("insights.workouts.manual.deleteError")); + toastWrittenOutcome("failed", t("insights.workouts.manual.deleteError")); } finally { setDeleting(false); } diff --git a/src/components/workouts/manual-workout-form.tsx b/src/components/workouts/manual-workout-form.tsx index d50f130f9..9902cac66 100644 --- a/src/components/workouts/manual-workout-form.tsx +++ b/src/components/workouts/manual-workout-form.tsx @@ -4,9 +4,9 @@ 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 { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; import { DateTimeField } from "@/components/ui/date-time-field"; import { FieldError } from "@/components/forms/field-error"; import { Input } from "@/components/ui/input"; @@ -166,11 +166,16 @@ export function ManualWorkoutForm({ setSaving(true); try { const outcome = await saveManualWorkout(built.entry, queryClient); - toast.success( - outcome === "duplicate" - ? t("insights.workouts.manual.alreadySaved") - : t("common.saved"), - ); + // A duplicate wrote nothing new: the same form already landed this + // session. That is reported as such, not as a fresh save. + if (outcome === "duplicate") { + toastWrittenOutcome( + "empty", + t("insights.workouts.manual.alreadySaved"), + ); + } else { + toastWrittenOutcome("success", t("common.saved")); + } onSuccess?.(); } catch (err) { setSaveError( diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index 39ea316f3..d54d2e68e 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -357,6 +357,7 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "workout.batch.payload_too_large", "workout.batch.source_not_permitted", "workout.batch.too_large", + "workout.delete.synced_source", ], "(unprefixed)": [ "about_me_conflict", From 074cccd920a05d39eae849adef18d882c5fbb40b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 2 Oct 2026 21:36:24 +0200 Subject: [PATCH 07/11] docs(api): regenerate the contract for the workout delete error code Refs #1085 --- docs/api/openapi.yaml | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index f0055fcf0..4ffd66eeb 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -31798,12 +31798,13 @@ components: vaccination.encounter-not-found, vaccination.identity-required, vaccination.invalid, vaccination.link.invalid, vaccination.not-found, vaccination.practitioner-not-found; workout — workout.batch.invalid, workout.batch.payload_too_large, workout.batch.source_not_permitted, - workout.batch.too_large; (unprefixed) — about_me_conflict, ai_response_truncated, coach_prefs_conflict, - dashboard_layout_conflict, empty_file, health_score_config_conflict, insights_layout_conflict, - invalid_base_updated_at, json_carries_no_intake_time, json_not_an_array, medication_layout_conflict, - missing_required_columns, modules_conflict, mood_tag_layout_conflict, not_configured, - notification_prefs_conflict, oidc_only, private_origin_not_approved, private_origin_not_grantable, - rate_limited_self, too_many_rows, unreadable_json, upstream_invalid_json, vapid_not_configured" + workout.batch.too_large, workout.delete.synced_source; (unprefixed) — about_me_conflict, + ai_response_truncated, coach_prefs_conflict, dashboard_layout_conflict, empty_file, + health_score_config_conflict, insights_layout_conflict, invalid_base_updated_at, + json_carries_no_intake_time, json_not_an_array, medication_layout_conflict, missing_required_columns, + modules_conflict, mood_tag_layout_conflict, not_configured, notification_prefs_conflict, oidc_only, + private_origin_not_approved, private_origin_not_grantable, rate_limited_self, too_many_rows, + unreadable_json, upstream_invalid_json, vapid_not_configured" type: string capability: description: "On an AI refusal: the capability that is unavailable, one of the keys of `AiCapabilities` (`coach`, From cf678f6ec4c310c5a045463b02b8214c200273b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 2 Oct 2026 21:38:06 +0200 Subject: [PATCH 08/11] fix(dashboard): gate every add-menu entry on its module, as the capture picker does The dashboard add menu asked only whether the record's section was writable, so a switched-off mood or medications module still offered its entry here while the bottom bar's picker hid it. The menu now renders from the picker's own visibleCaptureKinds, so the two cannot disagree. Refs #1085 --- .../__tests__/dashboard-header.test.tsx | 63 +++++++++++++++++++ src/components/dashboard/dashboard-header.tsx | 34 ++++++---- src/lib/workouts/manual-entry.ts | 5 +- 3 files changed, 88 insertions(+), 14 deletions(-) diff --git a/src/components/dashboard/__tests__/dashboard-header.test.tsx b/src/components/dashboard/__tests__/dashboard-header.test.tsx index 5ac00b99d..df1e6b7a6 100644 --- a/src/components/dashboard/__tests__/dashboard-header.test.tsx +++ b/src/components/dashboard/__tests__/dashboard-header.test.tsx @@ -44,6 +44,10 @@ vi.mock("@/hooks/use-auth", () => ({ })); import { DashboardHeader } from "../dashboard-header"; +import { + CAPTURE_KIND_ORDER, + visibleCaptureKinds, +} from "@/components/layout/capture-picker"; function renderSSR(node: React.ReactElement, locale: "en" | "de" = "de") { return renderToStaticMarkup( @@ -117,3 +121,62 @@ describe(" — Log workout", () => { expect(html).toContain("Log measurement"); }); }); + +describe(" — every entry follows its module, as the capture picker does", () => { + beforeEach(() => { + modulesRef.value = undefined; + }); + + it("withholds Log mood when the mood module is off", () => { + modulesRef.value = { mood: false }; + const html = renderSSR( + undefined} />, + "en", + ); + expect(html).not.toContain("Log mood"); + expect(html).toContain("Log medication intake"); + }); + + it("withholds Log medication intake when the medications module is off", () => { + modulesRef.value = { medications: false }; + const html = renderSSR( + undefined} />, + "en", + ); + expect(html).not.toContain("Log medication intake"); + expect(html).toContain("Log mood"); + }); + + it("offers exactly what the capture picker offers for the same modules", () => { + for (const modules of [ + undefined, + { mood: false }, + { medications: false }, + { workouts: false }, + { mood: false, medications: false, workouts: false }, + ]) { + modulesRef.value = modules; + const html = renderSSR( + undefined} />, + "en", + ); + const picker = visibleCaptureKinds( + { inSharedRecord: false, canWriteDomain: () => true }, + CAPTURE_KIND_ORDER, + modules, + ); + const label = { + measurement: "Log measurement", + medication: "Log medication intake", + mood: "Log mood", + workout: "Log workout", + } as const; + for (const kind of CAPTURE_KIND_ORDER) { + expect( + html.includes(label[kind]), + `${kind} ${JSON.stringify(modules)}`, + ).toBe(picker.includes(kind)); + } + } + }); +}); diff --git a/src/components/dashboard/dashboard-header.tsx b/src/components/dashboard/dashboard-header.tsx index cb2842708..db6ad7cc0 100644 --- a/src/components/dashboard/dashboard-header.tsx +++ b/src/components/dashboard/dashboard-header.tsx @@ -26,7 +26,10 @@ import { useAuth } from "@/hooks/use-auth"; import { useMounted } from "@/hooks/use-mounted"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { hourInTz } from "@/lib/tz/format"; -import { canLogWorkout } from "@/lib/workouts/manual-entry"; +import { + CAPTURE_KIND_ORDER, + visibleCaptureKinds, +} from "@/components/layout/capture-picker"; import type { QuickEntryDialog } from "@/components/dashboard/quick-entry-sheets"; export function DashboardHeader({ @@ -37,23 +40,30 @@ export function DashboardHeader({ const { t } = useTranslations(); const { user } = useAuth(); const mounted = useMounted(); - // The quick-add offers three kinds, and each one is asked about its own + // The quick-add offers four kinds, and each one is asked about its own // section: a reading under `measurements`, a dose under `medications`, a - // mood entry under `mind`. The coarse `canAdd` this used to gate the whole + // mood entry under `mind`, a workout in one's own record only. The coarse `canAdd` this used to gate the whole // menu on carries the grant's LEVEL and no scope, so it answered true for a // grant that opens neither of the two sections the menu never asked about. // The customize shortcut points at a settings page sharing does not cover at // all. const capabilities = useRecordCapabilities(); - const { canWriteDomain, inSharedRecord } = capabilities; - const canAddMeasurement = canWriteDomain("measurements"); - const canAddIntake = canWriteDomain("medications"); - // A mood entry is created at MANAGE under the mind section. - const canAddMood = canWriteDomain("mind"); - // A workout: own record only, workouts module on (`canLogWorkout`). - const canAddWorkout = canLogWorkout(capabilities, user?.modules); - const canAddAnything = - canAddMeasurement || canAddIntake || canAddMood || canAddWorkout; + const { inSharedRecord } = capabilities; + // The same answer the bottom bar's capture picker renders from: per kind, + // the record's section (a delegated grant) AND the kind's module + // (`capture:`). Asking the picker's own function is what keeps the two + // menus from disagreeing — this one used to check the section alone, so a + // switched-off mood module still offered "Log mood" here. + const offered = visibleCaptureKinds( + capabilities, + CAPTURE_KIND_ORDER, + user?.modules, + ); + const canAddMeasurement = offered.includes("measurement"); + const canAddIntake = offered.includes("medication"); + const canAddMood = offered.includes("mood"); + const canAddWorkout = offered.includes("workout"); + const canAddAnything = offered.length > 0; // The pre-hero greeting derivation, kept hydration-safe: `user` comes // from the auth query, which can resolve before this boundary diff --git a/src/lib/workouts/manual-entry.ts b/src/lib/workouts/manual-entry.ts index 5246eb6a2..d356ecc47 100644 --- a/src/lib/workouts/manual-entry.ts +++ b/src/lib/workouts/manual-entry.ts @@ -95,8 +95,9 @@ const ERR = "insights.workouts.manual.errors"; * 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. + * The workouts page asks this; the dashboard add menu and the capture picker + * ask `visibleCaptureKinds`, which applies the same two terms to the + * `workout` kind. */ export function canLogWorkout( caps: { inSharedRecord: boolean }, From 69f19ec23269c82c3b3d1d4ae42ff0175e9e933c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 2 Oct 2026 21:38:22 +0200 Subject: [PATCH 09/11] copy(workouts): mention manual entry in the empty workouts list Refs #1085 --- messages/de.json | 2 +- messages/en.json | 2 +- messages/es.json | 2 +- messages/fr.json | 2 +- messages/it.json | 2 +- messages/ko.json | 2 +- messages/pl.json | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/messages/de.json b/messages/de.json index 6a70427ba..fda2985a4 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3361,7 +3361,7 @@ "description": "Letzte Läufe, Touren, Spaziergänge und Krafteinheiten — quellenübergreifend entdoppelt zwischen Apple Watch und Withings.", "emptyState": { "title": "Noch keine Workouts", - "description": "Öffne die Apple-Health-App auf dem iPhone, damit Workouts hier synchronisiert werden. Withings-Aktivitätsimport pflegt Geh- und Radtouren mit ein." + "description": "Workouts kommen per Apple Health vom iPhone und von Withings. Du kannst eins auch von Hand erfassen." }, "detail": { "backToList": "Zurück zu Workouts", diff --git a/messages/en.json b/messages/en.json index 4732ebbf8..fb8f57815 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3361,7 +3361,7 @@ "description": "Recent runs, rides, walks and strength sessions, deduped across Apple Watch and Withings sources.", "emptyState": { "title": "No workouts yet", - "description": "Open the Apple Health app on your iPhone to sync workouts here. Withings activity import keeps walking and cycling sessions flowing in too." + "description": "Workouts sync from Apple Health on your iPhone and from Withings. You can also log one by hand." }, "detail": { "backToList": "Back to workouts", diff --git a/messages/es.json b/messages/es.json index e2acfc13d..2c0ac8ec5 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3361,7 +3361,7 @@ "description": "Carreras, salidas en bici, paseos y sesiones de fuerza recientes, deduplicados entre Apple Watch y Withings.", "emptyState": { "title": "Aún no hay entrenamientos", - "description": "Abre la app Apple Salud en el iPhone para sincronizar tus entrenamientos. La importación de actividad de Withings también incorpora caminatas y rutas en bici." + "description": "Los entrenamientos llegan desde Apple Salud en el iPhone y desde Withings. También puedes registrar uno a mano." }, "detail": { "backToList": "Volver a entrenamientos", diff --git a/messages/fr.json b/messages/fr.json index e1f5fedce..615da49db 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3361,7 +3361,7 @@ "description": "Courses, sorties à vélo, marches et séances de musculation récentes, dédupliquées entre Apple Watch et Withings.", "emptyState": { "title": "Aucun entraînement", - "description": "Ouvre l'app Apple Santé sur ton iPhone pour synchroniser tes entraînements. L'import d'activité Withings alimente également la marche et le vélo." + "description": "Tes entraînements arrivent d'Apple Santé sur l'iPhone et de Withings. Tu peux aussi en saisir un à la main." }, "detail": { "backToList": "Retour aux entraînements", diff --git a/messages/it.json b/messages/it.json index 8786c7020..b73b871ad 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3361,7 +3361,7 @@ "description": "Corse, uscite in bici, camminate e sessioni di forza recenti, deduplicati tra Apple Watch e Withings.", "emptyState": { "title": "Nessun allenamento", - "description": "Apri l'app Salute di Apple sull'iPhone per sincronizzare gli allenamenti. L'import di attività di Withings porta dentro anche camminate e uscite in bici." + "description": "Gli allenamenti arrivano da Salute di Apple sull'iPhone e da Withings. Puoi anche registrarne uno a mano." }, "detail": { "backToList": "Torna agli allenamenti", diff --git a/messages/ko.json b/messages/ko.json index 32087a7d4..f790fb3c7 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -3361,7 +3361,7 @@ "description": "최근 달리기, 자전거, 걷기, 근력 운동 세션이에요. Apple Watch와 Withings 소스 사이 중복은 정리했어요.", "emptyState": { "title": "아직 운동 기록이 없어요", - "description": "iPhone에서 Apple Health 앱을 열면 운동이 여기로 동기화돼요. Withings 활동 불러오기로 걷기와 자전거 세션도 계속 들어와요." + "description": "운동은 iPhone의 Apple Health와 Withings에서 동기화돼요. 직접 기록할 수도 있어요." }, "detail": { "backToList": "운동으로 돌아가기", diff --git a/messages/pl.json b/messages/pl.json index dafd7d8c8..108c13958 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3361,7 +3361,7 @@ "description": "Ostatnie biegi, jazdy, marsze i treningi siłowe — zdeduplikowane między Apple Watch a Withings.", "emptyState": { "title": "Brak treningów", - "description": "Otwórz aplikację Apple Zdrowie na iPhonie, aby zsynchronizować treningi. Import aktywności z Withings także dorzuca marsze i przejażdżki." + "description": "Treningi trafiają tu z Apple Zdrowie na iPhonie i z Withings. Możesz też wpisać trening ręcznie." }, "detail": { "backToList": "Powrót do treningów", From d9dfa7e5db0235298e9d9cfdaf422ba035f774dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 2 Oct 2026 21:44:37 +0200 Subject: [PATCH 10/11] test(dashboard): type the module cases of the add-menu parity check Refs #1085 --- src/components/dashboard/__tests__/dashboard-header.test.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/dashboard/__tests__/dashboard-header.test.tsx b/src/components/dashboard/__tests__/dashboard-header.test.tsx index df1e6b7a6..0e5579c44 100644 --- a/src/components/dashboard/__tests__/dashboard-header.test.tsx +++ b/src/components/dashboard/__tests__/dashboard-header.test.tsx @@ -148,13 +148,14 @@ describe(" — every entry follows its module, as the capture p }); it("offers exactly what the capture picker offers for the same modules", () => { - for (const modules of [ + const cases: Array | undefined> = [ undefined, { mood: false }, { medications: false }, { workouts: false }, { mood: false, medications: false, workouts: false }, - ]) { + ]; + for (const modules of cases) { modulesRef.value = modules; const html = renderSSR( undefined} />, From 9e239dd6c5fb069bf606fdb16d008477c71c9b3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 2 Oct 2026 22:30:09 +0200 Subject: [PATCH 11/11] build: re-measure the aggregate client budget for the manual workout entry Main measured 3718 KB gz after v1.39.7 and v1.39.8, almost all of it new copy in the seven lazy locale catalogs; the workout form, its sheet, the delete action and their copy add about 7 KB (3725). The cap moves from 3724 to 3749, the same 24 KB headroom as before; the eager routes stay inside their drift allowances. Refs #1085 --- bundle-budget.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bundle-budget.json b/bundle-budget.json index 073abc99e..43ee910d7 100644 --- a/bundle-budget.json +++ b/bundle-budget.json @@ -1,5 +1,5 @@ { - "$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.27 found /insights sitting a few hundred bytes under 445 on main and a few hundred over it on the release branch — the page had been at its ceiling for three releases and the release only had to breathe on it. The weight it was carrying was not its own: the authenticated shell imported one destination predicate from the `@/lib/record-settings` barrel, and the barrel also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode into the eager bundle of every authenticated route for a function that reads a URL slug. The shell imports the `classification` leaf now. Measured across all 585 client-rendered routes: none grew by a byte, 100 fell, by 4.7 KB at the median and 8.1 KB at the most; /insights 444 to 437, /measurements 442 to 435, / 456 to 450, /insights/mood 455 to 450. The whole-build sum moved the other way, 3451 to 3476, and that direction is the metric rather than the change: the aggregate counts each emitted chunk once, so a graph that leaves the chunk group every route shares gets counted again wherever it lands — here inside the deferred settings chunks that actually use it. Bytes on disk rose 25 KB; bytes a reader downloads only ever fell. The aggregate cap moves from 3452 to 3500, the same 24 KB headroom as the four re-measurements before it. Per-route ceilings keep their old values rather than tightening to the new floor: the CI runner measures each route about 1.5 KB heavier than a local arm64 build does, and a ceiling set from a local number would fail on a build that had not regressed. The static-catalog guard and the one-Recharts-chunk guard remain unchanged. Raised to 3700 at v1.37.32 for the seventh interface language (Korean): the lazy message catalogs went 6->7 chunks (1026 KB gz measured), pushing the aggregate to 3631 against the old 3500. The raise is entirely the new catalog; no route budget moved, and the four eager route ceilings are unchanged and still passing. Most of the aggregate is the dynamic locale-switch and fallback path that a first paint never loads (see issue #253), so this ceiling guards bytes almost no visitor requests; 3700 leaves ~69 KB for ordinary drift until that double-count is cleaned up. v1.38.14 re-measures the four eager routes and changes what the number means. The ceilings had been re-stated but never re-based since v1.37.4, so /insights and /measurements sat within a kilobyte of theirs and / and /insights/mood within one of theirs; e2e run 34043772954 failed on `/insights/page: 445 KB gz exceeds the 445 KB budget` with the other two at 459 of 460, and none of it was a regression anyone had made. A ceiling a route is already touching does not measure the route, it measures whichever dependency bump landed that week. The file states the measured value per route now, plus one drift allowance over it: measured on a local arm64 build at d48bbff01 as / 447, /insights 434, /measurements 433, /insights/mood 447, with a 20 KB allowance, so the effective ceilings are 467 / 454 / 453 / 467 — a raise for all four. The allowance covers the runner delta (the CI runner measures each route a little heavier than a local arm64 build) and ordinary week-to-week drift, while the step change the gate exists for — a statically imported message catalog, a duplicated chart library — is several times larger and still fails. Every run now prints the delta against the baseline, so a route that has crept 15 KB is legible in the log long before it is red; the flat ceiling could only ever say pass or fail. Re-base by re-measuring on a build with nothing else in it and writing the new numbers here with the reason, exactly as a raised ceiling was written before. Raised to 3724 at v1.39.4 on a measured delta, not a feature. The release branch alone measured 3699 KB gz (3698.9) against the 3700 cap, so the ordinary drift room the v1.37.32 raise left had been used up by the releases since; the review fixes on top of it added 1.3 KB gz across the whole build (3700.2), almost all of it new copy in the seven lazy locale catalogs (the total and reused-table labels, the step and chip wording) plus the result-panel states. The four eager routes measured the same on both builds (/ 458, /insights/mood 456, /insights 445, /measurements 442), all inside their ceilings. The aggregate cap moves from 3700 to 3724, the same 24 KB headroom as the re-measurements before it.", + "$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.27 found /insights sitting a few hundred bytes under 445 on main and a few hundred over it on the release branch — the page had been at its ceiling for three releases and the release only had to breathe on it. The weight it was carrying was not its own: the authenticated shell imported one destination predicate from the `@/lib/record-settings` barrel, and the barrel also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode into the eager bundle of every authenticated route for a function that reads a URL slug. The shell imports the `classification` leaf now. Measured across all 585 client-rendered routes: none grew by a byte, 100 fell, by 4.7 KB at the median and 8.1 KB at the most; /insights 444 to 437, /measurements 442 to 435, / 456 to 450, /insights/mood 455 to 450. The whole-build sum moved the other way, 3451 to 3476, and that direction is the metric rather than the change: the aggregate counts each emitted chunk once, so a graph that leaves the chunk group every route shares gets counted again wherever it lands — here inside the deferred settings chunks that actually use it. Bytes on disk rose 25 KB; bytes a reader downloads only ever fell. The aggregate cap moves from 3452 to 3500, the same 24 KB headroom as the four re-measurements before it. Per-route ceilings keep their old values rather than tightening to the new floor: the CI runner measures each route about 1.5 KB heavier than a local arm64 build does, and a ceiling set from a local number would fail on a build that had not regressed. The static-catalog guard and the one-Recharts-chunk guard remain unchanged. Raised to 3700 at v1.37.32 for the seventh interface language (Korean): the lazy message catalogs went 6->7 chunks (1026 KB gz measured), pushing the aggregate to 3631 against the old 3500. The raise is entirely the new catalog; no route budget moved, and the four eager route ceilings are unchanged and still passing. Most of the aggregate is the dynamic locale-switch and fallback path that a first paint never loads (see issue #253), so this ceiling guards bytes almost no visitor requests; 3700 leaves ~69 KB for ordinary drift until that double-count is cleaned up. v1.38.14 re-measures the four eager routes and changes what the number means. The ceilings had been re-stated but never re-based since v1.37.4, so /insights and /measurements sat within a kilobyte of theirs and / and /insights/mood within one of theirs; e2e run 34043772954 failed on `/insights/page: 445 KB gz exceeds the 445 KB budget` with the other two at 459 of 460, and none of it was a regression anyone had made. A ceiling a route is already touching does not measure the route, it measures whichever dependency bump landed that week. The file states the measured value per route now, plus one drift allowance over it: measured on a local arm64 build at d48bbff01 as / 447, /insights 434, /measurements 433, /insights/mood 447, with a 20 KB allowance, so the effective ceilings are 467 / 454 / 453 / 467 — a raise for all four. The allowance covers the runner delta (the CI runner measures each route a little heavier than a local arm64 build) and ordinary week-to-week drift, while the step change the gate exists for — a statically imported message catalog, a duplicated chart library — is several times larger and still fails. Every run now prints the delta against the baseline, so a route that has crept 15 KB is legible in the log long before it is red; the flat ceiling could only ever say pass or fail. Re-base by re-measuring on a build with nothing else in it and writing the new numbers here with the reason, exactly as a raised ceiling was written before. Raised to 3724 at v1.39.4 on a measured delta, not a feature. The release branch alone measured 3699 KB gz (3698.9) against the 3700 cap, so the ordinary drift room the v1.37.32 raise left had been used up by the releases since; the review fixes on top of it added 1.3 KB gz across the whole build (3700.2), almost all of it new copy in the seven lazy locale catalogs (the total and reused-table labels, the step and chip wording) plus the result-panel states. The four eager routes measured the same on both builds (/ 458, /insights/mood 456, /insights 445, /measurements 442), all inside their ceilings. The aggregate cap moves from 3700 to 3724, the same 24 KB headroom as the re-measurements before it. Raised to 3749 at v1.39.9 on a measured delta. Main measured 3718 KB gz after v1.39.7 and v1.39.8 (unit-aware copy, the phone layouts, the time-zone work and the account-settings restore labels, almost all of it new strings in the seven lazy locale catalogs), leaving 6 KB of the v1.39.4 headroom; the manual workout entry (#1085: the form, its sheet, the delete action and their copy in seven locales) adds about 7 KB and measured 3725. The four eager routes stay inside their drift allowances (/ +12.5, /insights +12.1, /insights/mood +10.2 KB of 20 on main). The aggregate cap moves from 3724 to 3749, the same 24 KB headroom as the re-measurements before it.", "routeBaselineKbGz": { "/page": 447, "/insights/page": 434, @@ -7,6 +7,6 @@ "/insights/mood/page": 447 }, "routeDriftAllowanceKbGz": 20, - "totalClientKbGz": 3724, + "totalClientKbGz": 3749, "maxRechartsChunks": 1 }