diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index eb8af506b..21d03505e 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -45064,6 +45064,22 @@ components: minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" + storedAvgHr: + description: The row's own average heart rate (v1.42). `avgHr` may be filled in from a same-session twin by the + canonical picker; this is the stored value, which an edit of a hand-entered workout sends back so the twin's + value is not written onto it. + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + storedMaxHr: + description: The row's own maximum heart rate (v1.42), as `storedAvgHr` is to `avgHr`. + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" stepCount: anyOf: - type: integer diff --git a/src/__tests__/i18n-reverse-coverage.test.ts b/src/__tests__/i18n-reverse-coverage.test.ts index 4a2083a39..ecf56219c 100644 --- a/src/__tests__/i18n-reverse-coverage.test.ts +++ b/src/__tests__/i18n-reverse-coverage.test.ts @@ -91,9 +91,6 @@ const RELEASE_PENDING_PREFIXES: Record = { "environment.fields.europeanAqi": "v1.42 air-quality field label (#615)", "environment.fields.uvIndexMax": "v1.42 air-quality field label (#615)", "environment.fields.dustMax": "v1.42 air-quality field label (#615)", - "insights.workouts.manual.editTitle": "v1.42 manual workout edit (#1162)", - "insights.workouts.manual.editAction": "v1.42 manual workout edit (#1162)", - "insights.workouts.manual.updated": "v1.42 manual workout edit (#1162)", "vaccinations.custom": "v1.42 user-defined vaccines (#1005)", "labs.convertedFrom": "v1.42 lab unit conversion note (#1095)", }; diff --git a/src/app/api/workouts/[id]/route.ts b/src/app/api/workouts/[id]/route.ts index a5ff667fa..033cebee8 100644 --- a/src/app/api/workouts/[id]/route.ts +++ b/src/app/api/workouts/[id]/route.ts @@ -335,6 +335,12 @@ export const GET = apiHandler( avgHr: effectiveAvgHeartRate, maxHr: effectiveMaxHeartRate, minHr: row.minHeartRate, + // The row's own heart rate. `avgHr` / `maxHr` above may be filled in + // from a twin by the canonical picker, which is right for display and + // wrong for an edit: re-posting them would store the twin's values on + // the hand-entered row (#1162). + storedAvgHr: row.avgHeartRate, + storedMaxHr: row.maxHeartRate, stepCount: row.stepCount, elevationM: row.elevationM, pauseDurationSec: row.pauseDurationSec, diff --git a/src/app/insights/workouts/[id]/page.tsx b/src/app/insights/workouts/[id]/page.tsx index 4d8d66d64..338d55b20 100644 --- a/src/app/insights/workouts/[id]/page.tsx +++ b/src/app/insights/workouts/[id]/page.tsx @@ -11,6 +11,7 @@ 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 { EditWorkoutButton } from "@/components/workouts/edit-workout-button"; import { WorkoutDetailHeader, WorkoutDetailStats, @@ -65,10 +66,21 @@ 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. + // A workout entered by hand can be edited (#1162) and deleted here; + // both controls render nothing for a synced one. headerAction={ - data && !canonicalHref ? : null + data && !canonicalHref ? ( +
+ + +
+ ) : null } backLink={ { }); }); }); + +describe(" — editing a stored workout (#1162)", () => { + const original = manualWorkoutOriginalFromRow( + { + sportType: "cycling", + startedAt: "2026-09-15T06:00:00.000Z", + endedAt: "2026-09-15T07:30:00.000Z", + durationSec: 5400, + distanceM: 32000, + activeEnergyKcal: 640, + minHr: null, + stepCount: null, + elevationM: null, + pauseDurationSec: null, + storedAvgHr: 131, + storedMaxHr: null, + }, + { timezone: "Europe/Berlin", unitPreference: "metric" }, + ); + + it("opens on the stored values", () => { + const html = render( + , + ); + expect(html).toContain('value="2026-09-15T08:00"'); + expect(html).toContain('value="1"'); + expect(html).toContain('value="30"'); + expect(html).toContain('value="32"'); + expect(html).toContain('value="640"'); + }); + + it("opens empty when it is not editing", () => { + const html = render(); + expect(html).not.toContain('value="640"'); + }); +}); diff --git a/src/components/workouts/edit-workout-button.tsx b/src/components/workouts/edit-workout-button.tsx new file mode 100644 index 000000000..ba9333959 --- /dev/null +++ b/src/components/workouts/edit-workout-button.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { Pencil } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { ManualWorkoutSheet } from "@/components/workouts/manual-workout-sheet"; +import { useAuth } from "@/hooks/use-auth"; +import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; +import { useUnitDisplay } from "@/hooks/use-unit-display"; +import { useTranslations } from "@/lib/i18n/context"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; +import { cn } from "@/lib/utils"; +import { + canEditWorkout, + manualWorkoutOriginalFromRow, + type StoredManualWorkout, +} from "@/lib/workouts/manual-entry"; + +/** + * The detail page's edit control (#1162): a ghost icon beside delete that + * opens the "Log workout" sheet on the stored values. Renders nothing for a + * workout it may not edit (a synced one, or somebody else's record). + * + * Saving goes through the same `POST /api/workouts/batch` the form always + * used, re-posting the row's own `manual:` id, which the route applies as an + * overwrite of that one row. No separate edit route exists. + */ +export function EditWorkoutButton({ + workout, +}: { + workout: StoredManualWorkout & { + source: string; + externalId: string | null; + }; +}) { + const { t } = useTranslations(); + const { user } = useAuth(); + const { preference } = useUnitDisplay(); + const capabilities = useRecordCapabilities(); + const [open, setOpen] = useState(false); + const timezone = user?.timezone || DEFAULT_TIMEZONE; + + const original = useMemo( + () => + manualWorkoutOriginalFromRow(workout, { + timezone, + unitPreference: preference, + }), + [workout, timezone, preference], + ); + + if (!canEditWorkout(workout, capabilities) || !workout.externalId) { + return null; + } + + return ( + <> + + {/* The sheet mounts its form only while open, so each opening starts + from the values the page shows at that moment. */} + + + ); +} diff --git a/src/components/workouts/manual-workout-form.tsx b/src/components/workouts/manual-workout-form.tsx index 4455c83ae..b3f3a40e5 100644 --- a/src/components/workouts/manual-workout-form.tsx +++ b/src/components/workouts/manual-workout-form.tsx @@ -1,9 +1,9 @@ "use client"; -import { useId, useMemo, useState } from "react"; +import { useEffect, 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 { Check, Loader2, Plus } from "lucide-react"; import { Button } from "@/components/ui/button"; import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; @@ -33,6 +33,7 @@ import { type ManualWorkoutDraft, type ManualWorkoutEntry, type ManualWorkoutField, + type ManualWorkoutOriginal, } from "@/lib/workouts/manual-entry"; import { workoutSportTypeEnum, @@ -49,10 +50,23 @@ import { * `onSuccess` / `onCancel` close the sheet. */ +/** + * Edit mode (#1162): the stored workout's own `manual:` id and the values it + * opens with. Saving re-posts that id, which the batch route applies as an + * overwrite of that one row. + */ +export interface ManualWorkoutEdit { + externalId: string; + original: ManualWorkoutOriginal; +} + interface ManualWorkoutFormProps { onSuccess?: () => void; onCancel?: () => void; footerSlot?: HTMLElement | null; + edit?: ManualWorkoutEdit; + /** Whether the fields differ from what the form opened with. */ + onDirtyChange?: (dirty: boolean) => void; } interface BatchResult { @@ -104,6 +118,8 @@ export function ManualWorkoutForm({ onSuccess, onCancel, footerSlot, + edit, + onDirtyChange, }: ManualWorkoutFormProps) { const { t } = useTranslations(); const queryClient = useQueryClient(); @@ -114,10 +130,20 @@ export function ManualWorkoutForm({ // 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, and // a submit after an edit updates the row the first submit stored. - const [externalId] = useState(newManualWorkoutExternalId); - const [draft, setDraft] = useState(() => - emptyManualWorkoutDraft(), + // An edit sends the stored row's own id instead. + const [externalId] = useState( + () => edit?.externalId ?? newManualWorkoutExternalId(), ); + const [initialDraft] = useState( + () => edit?.original.draft ?? emptyManualWorkoutDraft(), + ); + const [draft, setDraft] = useState(initialDraft); + const dirty = ( + Object.keys(initialDraft) as Array + ).some((key) => draft[key] !== initialDraft[key]); + useEffect(() => { + onDirtyChange?.(dirty); + }, [dirty, onDirtyChange]); const [errors, setErrors] = useState< Partial> >({}); @@ -158,6 +184,7 @@ export function ManualWorkoutForm({ unitPreference: preference, externalId, now: new Date(), + original: edit?.original, }); if (!built.ok) { setErrors(built.errors); @@ -174,6 +201,8 @@ export function ManualWorkoutForm({ "empty", t("insights.workouts.manual.alreadySaved"), ); + } else if (outcome === "updated") { + toastWrittenOutcome("success", t("insights.workouts.manual.updated")); } else { toastWrittenOutcome("success", t("common.saved")); } @@ -216,6 +245,8 @@ export function ManualWorkoutForm({ > {saving ? ( + ) : edit ? ( + ) : ( )} diff --git a/src/components/workouts/manual-workout-sheet.tsx b/src/components/workouts/manual-workout-sheet.tsx index 47903471f..106fbb0e7 100644 --- a/src/components/workouts/manual-workout-sheet.tsx +++ b/src/components/workouts/manual-workout-sheet.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useCallback, useRef, useState } from "react"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { @@ -14,7 +14,10 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { sheetBodyHasUnsavedInput } from "@/components/dashboard/quick-entry-sheets"; -import { ManualWorkoutForm } from "@/components/workouts/manual-workout-form"; +import { + ManualWorkoutForm, + type ManualWorkoutEdit, +} from "@/components/workouts/manual-workout-form"; import { useTranslations } from "@/lib/i18n/context"; /** @@ -23,21 +26,31 @@ import { useTranslations } from "@/lib/i18n/context"; * `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. + * + * With `edit` it opens on a stored workout (#1162). Its fields start filled, + * so "has input" says nothing there; the guard asks the form whether anything + * differs from what it opened with. */ export function ManualWorkoutSheet({ open, onOpenChange, + edit, }: { open: boolean; onOpenChange: (open: boolean) => void; + edit?: ManualWorkoutEdit; }) { const { t } = useTranslations(); const [footerEl, setFooterEl] = useState(null); const [confirmDiscardOpen, setConfirmDiscardOpen] = useState(false); + const editDirty = useRef(false); + const handleDirtyChange = useCallback((dirty: boolean) => { + editDirty.current = dirty; + }, []); function handleOpenChange(next: boolean) { if (next) return onOpenChange(true); - if (sheetBodyHasUnsavedInput()) { + if (edit ? editDirty.current : sheetBodyHasUnsavedInput()) { setConfirmDiscardOpen(true); return; } @@ -49,7 +62,11 @@ export function ManualWorkoutSheet({ } > @@ -58,6 +75,8 @@ export function ManualWorkoutSheet({ onSuccess={() => onOpenChange(false)} onCancel={() => onOpenChange(false)} footerSlot={footerEl} + edit={edit} + onDirtyChange={edit ? handleDirtyChange : undefined} /> )} diff --git a/src/hooks/use-workouts.ts b/src/hooks/use-workouts.ts index f37d0ec33..9b08342d2 100644 --- a/src/hooks/use-workouts.ts +++ b/src/hooks/use-workouts.ts @@ -305,6 +305,13 @@ export interface WorkoutDetailPayload extends WorkoutListEntry { */ dayKey: string; minHr: number | null; + /** + * The row's own heart rate, before the canonical picker filled any of it + * in from a twin. An edit of a hand-entered workout sends these back + * (#1162); `avgHr` / `maxHr` are for display. + */ + storedAvgHr?: number | null; + storedMaxHr?: number | null; stepCount: number | null; elevationM: number | null; pauseDurationSec: number | null; diff --git a/src/lib/openapi/routes/workouts.ts b/src/lib/openapi/routes/workouts.ts index 037b49bb5..e3c4678a1 100644 --- a/src/lib/openapi/routes/workouts.ts +++ b/src/lib/openapi/routes/workouts.ts @@ -221,6 +221,22 @@ const workoutDetailResponse = z avgHr: z.number().int().nullable(), maxHr: z.number().int().nullable(), minHr: z.number().int().nullable(), + storedAvgHr: z + .number() + .int() + .nullable() + .optional() + .describe( + "The row's own average heart rate (v1.42). `avgHr` may be filled in from a same-session twin by the canonical picker; this is the stored value, which an edit of a hand-entered workout sends back so the twin's value is not written onto it.", + ), + storedMaxHr: z + .number() + .int() + .nullable() + .optional() + .describe( + "The row's own maximum heart rate (v1.42), as `storedAvgHr` is to `avgHr`.", + ), stepCount: z.number().int().nullable(), elevationM: z.number().nullable(), pauseDurationSec: z.number().int().nullable(), diff --git a/src/lib/workouts/__tests__/manual-entry.test.ts b/src/lib/workouts/__tests__/manual-entry.test.ts index 001142455..be9902419 100644 --- a/src/lib/workouts/__tests__/manual-entry.test.ts +++ b/src/lib/workouts/__tests__/manual-entry.test.ts @@ -2,7 +2,10 @@ import { describe, expect, it } from "vitest"; import { buildManualWorkoutEntry, + canEditWorkout, canLogWorkout, + manualWorkoutOriginalFromRow, + type StoredManualWorkout, emptyManualWorkoutDraft, newManualWorkoutExternalId, wallClockNow, @@ -283,3 +286,132 @@ describe("canLogWorkout — who is offered the entry", () => { expect(canLogWorkout(SHARED, { workouts: true })).toBe(false); }); }); + +describe("editing a stored workout (#1162)", () => { + // 07:12:34 in Berlin, 47 min 20 s, 5 000 m, with values the form has no + // field for (written by a client other than this form). + const ROW: StoredManualWorkout = { + sportType: "running", + startedAt: "2026-09-15T05:12:34.000Z", + endedAt: "2026-09-15T05:59:54.000Z", + durationSec: 2840, + distanceM: 5000, + activeEnergyKcal: 410, + minHr: 98, + stepCount: 6400, + elevationM: 42.5, + pauseDurationSec: 60, + storedAvgHr: 142, + storedMaxHr: 171, + }; + const EDIT_ID = "manual:7c1d2e3f-aaaa-4bbb-8ccc-dddddddddddd"; + + function original(unitPreference: "metric" | "imperial" = "metric") { + return manualWorkoutOriginalFromRow(ROW, { + timezone: "Europe/Berlin", + unitPreference, + }); + } + + function save( + edit: Partial, + unitPreference: "metric" | "imperial" = "metric", + ) { + const o = original(unitPreference); + const result = buildManualWorkoutEntry( + { ...o.draft, ...edit }, + { ...CTX, unitPreference, externalId: EDIT_ID, original: o }, + ); + if (!result.ok) throw new Error(JSON.stringify(result.errors)); + return result.entry; + } + + it("opens on the stored values, in the profile zone and the reader's unit", () => { + expect(original().draft).toEqual({ + sportType: "running", + start: "2026-09-15T07:12", + hours: "0", + minutes: "47", + distance: "5", + energyKcal: "410", + }); + expect(original("imperial").draft.distance).toBe("3.11"); + }); + + it("saves an untouched edit as exactly the stored row", () => { + expect(save({}, "imperial")).toEqual({ + sportType: "running", + startedAt: ROW.startedAt, + endedAt: ROW.endedAt, + source: "MANUAL", + externalId: EDIT_ID, + totalDistanceM: 5000, + totalEnergyKcal: 410, + avgHeartRate: 142, + maxHeartRate: 171, + minHeartRate: 98, + stepCount: 6400, + elevationM: 42.5, + pauseDurationSec: 60, + }); + }); + + // The batch route nulls every overwritable column a re-post leaves out, so + // an edit of one field must carry the rest or it erases them. + it("keeps heart rate, steps and the rest when only the energy changes", () => { + const entry = save({ energyKcal: "450" }); + expect(entry.totalEnergyKcal).toBe(450); + expect(entry).toMatchObject({ + avgHeartRate: 142, + maxHeartRate: 171, + minHeartRate: 98, + stepCount: 6400, + elevationM: 42.5, + pauseDurationSec: 60, + totalDistanceM: 5000, + startedAt: ROW.startedAt, + }); + }); + + it("re-derives start and end from the fields once either changes", () => { + const entry = save({ minutes: "50" }); + // 07:12 Berlin = 05:12Z, now to the minute, plus 50 minutes. + expect(entry.startedAt).toBe("2026-09-15T05:12:00.000Z"); + expect(entry.endedAt).toBe("2026-09-15T06:02:00.000Z"); + }); + + it("clears a distance the person emptied", () => { + expect(save({ distance: "" })).not.toHaveProperty("totalDistanceM"); + }); + + it("is accepted by the batch route's own schema", () => { + const parsed = createBatchWorkoutSchema.safeParse({ + workouts: [save({ energyKcal: "450" })], + }); + expect(parsed.success).toBe(true); + }); + + it("is offered for a form-entered workout in one's own record only", () => { + const own = { inSharedRecord: false }; + expect(canEditWorkout({ source: "MANUAL", externalId: EDIT_ID }, own)).toBe( + true, + ); + expect( + canEditWorkout( + { source: "MANUAL", externalId: EDIT_ID }, + { inSharedRecord: true }, + ), + ).toBe(false); + expect( + canEditWorkout({ source: "APPLE_HEALTH", externalId: EDIT_ID }, own), + ).toBe(false); + // A MANUAL row without the form's id would not be overwritten by a + // re-post, so it is not offered. + expect(canEditWorkout({ source: "MANUAL", externalId: null }, own)).toBe( + false, + ); + expect( + canEditWorkout({ source: "MANUAL", externalId: "uuid-123" }, own), + ).toBe(false); + }); +}); diff --git a/src/lib/workouts/manual-entry.ts b/src/lib/workouts/manual-entry.ts index d672ae8ea..09e87eca6 100644 --- a/src/lib/workouts/manual-entry.ts +++ b/src/lib/workouts/manual-entry.ts @@ -19,6 +19,7 @@ import { z } from "zod/v4"; import { + applyDisplayTransform, getQuantityTransform, invertDisplayTransform, type UnitPreference, @@ -59,8 +60,24 @@ export interface ManualWorkoutDraft { export type ManualWorkoutField = "sportType" | "start" | "duration" | "distance" | "energyKcal"; +/** + * The columns of a hand-entered workout the form has no field for. The batch + * route's overwrite of a `manual:` row replaces EVERY overwritable column, + * nulling the ones a re-post leaves out, so an edit sends these back exactly + * as stored. A MANUAL row can carry them when a client other than this form + * wrote it (a phone shortcut posting heart rate or steps). + */ +export interface ManualWorkoutCarried { + avgHeartRate?: number; + maxHeartRate?: number; + minHeartRate?: number; + stepCount?: number; + elevationM?: number; + pauseDurationSec?: number; +} + /** The one entry of the `POST /api/workouts/batch` body this form sends. */ -export interface ManualWorkoutEntry { +export interface ManualWorkoutEntry extends ManualWorkoutCarried { sportType: WorkoutSportType; startedAt: string; endedAt: string; @@ -70,6 +87,23 @@ export interface ManualWorkoutEntry { totalEnergyKcal?: number; } +/** + * The stored row an edit starts from (#1162): the draft it was shown as, and + * the stored values behind it. A field whose text the person left as shown + * sends the stored value, not the value re-parsed from that text, so opening + * and saving an edit never moves a start by its seconds or a distance by the + * rounding of its display (5 000 m shown as 3.11 mi would come back as + * 5 005.1 m). + */ +export interface ManualWorkoutOriginal { + draft: ManualWorkoutDraft; + startedAt: string; + endedAt: string; + totalDistanceM: number | null; + totalEnergyKcal: number | null; + carried: ManualWorkoutCarried; +} + export interface ManualWorkoutContext { /** The profile timezone the start is read in. */ timezone: string; @@ -77,6 +111,8 @@ export interface ManualWorkoutContext { /** The id this form instance sends with every submit. */ externalId: string; now: Date; + /** Set when the form edits a stored workout rather than logging one. */ + original?: ManualWorkoutOriginal; } export type ManualWorkoutResult = @@ -106,6 +142,90 @@ export function canLogWorkout( return !caps.inSharedRecord && isSurfaceVisible("capture:workout", modules); } +/** + * Whether a workout can be edited from its detail page (#1162): one entered + * by hand through the form, in one's own record. The edit is a re-post of the + * row's own `manual:` id through `POST /api/workouts/batch`, which the route + * treats as an overwrite of that row and of nothing else, so a MANUAL row + * without such an id (none is expected; the form has always minted one) can + * still be deleted but not edited. + */ +export function canEditWorkout( + workout: { source: string; externalId: string | null }, + caps: { inSharedRecord: boolean }, +): boolean { + return ( + workout.source === "MANUAL" && + typeof workout.externalId === "string" && + workout.externalId.startsWith("manual:") && + !caps.inSharedRecord + ); +} + +/** The stored workout an edit opens from, in the detail read's field names. */ +export interface StoredManualWorkout { + sportType: string; + startedAt: string; + endedAt: string; + durationSec: number; + distanceM: number | null; + activeEnergyKcal: number | null; + minHr: number | null; + stepCount: number | null; + elevationM: number | null; + pauseDurationSec: number | null; + /** The row's own heart rate, before any twin filled it in. */ + storedAvgHr: number | null; + storedMaxHr: number | null; +} + +/** + * The draft a stored workout is shown as, and the stored values behind it. + * The start reads in the profile timezone to the minute, the duration in + * whole minutes, the distance in the reader's unit at its display precision. + */ +export function manualWorkoutOriginalFromRow( + row: StoredManualWorkout, + opts: { timezone: string; unitPreference: UnitPreference }, +): ManualWorkoutOriginal { + const sport = workoutSportTypeEnum.safeParse(row.sportType); + const totalMinutes = Math.max(0, Math.round(row.durationSec / 60)); + const distance = + row.distanceM === null + ? "" + : String( + applyDisplayTransform( + row.distanceM, + getQuantityTransform("distance", opts.unitPreference), + ), + ); + const carried: ManualWorkoutCarried = {}; + if (row.storedAvgHr !== null) carried.avgHeartRate = row.storedAvgHr; + if (row.storedMaxHr !== null) carried.maxHeartRate = row.storedMaxHr; + if (row.minHr !== null) carried.minHeartRate = row.minHr; + if (row.stepCount !== null) carried.stepCount = row.stepCount; + if (row.elevationM !== null) carried.elevationM = row.elevationM; + if (row.pauseDurationSec !== null) { + carried.pauseDurationSec = row.pauseDurationSec; + } + return { + draft: { + sportType: sport.success ? sport.data : "", + start: wallClockNow(new Date(row.startedAt), opts.timezone), + hours: String(Math.floor(totalMinutes / 60)), + minutes: String(totalMinutes % 60), + distance, + energyKcal: + row.activeEnergyKcal === null ? "" : String(row.activeEnergyKcal), + }, + startedAt: new Date(row.startedAt).toISOString(), + endedAt: new Date(row.endedAt).toISOString(), + totalDistanceM: row.distanceM, + totalEnergyKcal: row.activeEnergyKcal, + carried, + }; +} + /** A fresh id for one opened form. The prefix says where the row came from. */ export function newManualWorkoutExternalId(): string { return `manual:${randomId()}`; @@ -306,26 +426,51 @@ export function buildManualWorkoutEntry( } // Every branch below was proven by the schema above. + const original = ctx.original; const durationSec = ((parseWhole(draft.hours) ?? 0) * 60 + (parseWhole(draft.minutes) ?? 0)) * 60; - const startedAt = - draft.start.trim() === "" + // An edit whose start and duration read as shown keeps the stored instants + // to the second; changing either re-derives both from the fields. + const timesAsShown = + original !== undefined && + draft.start === original.draft.start && + draft.hours === original.draft.hours && + draft.minutes === original.draft.minutes; + const startedAt = timesAsShown + ? new Date(original.startedAt) + : draft.start.trim() === "" ? new Date(ctx.now.getTime() - durationSec * 1000) : startToUtc(draft.start, ctx.timezone)!; + const endedAt = timesAsShown + ? new Date(original.endedAt) + : new Date(startedAt.getTime() + durationSec * 1000); 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(), + endedAt: endedAt.toISOString(), source: "MANUAL", externalId: ctx.externalId, + // Everything the form has no field for goes back as stored, because the + // overwrite nulls whatever a re-post leaves out. + ...(original?.carried ?? {}), }; - if (distance !== null) { + if (original && draft.distance === original.draft.distance) { + if (original.totalDistanceM !== null) { + entry.totalDistanceM = original.totalDistanceM; + } + } else if (distance !== null) { entry.totalDistanceM = toMetres(distance, ctx.unitPreference); } - if (energy !== null) entry.totalEnergyKcal = energy; + if (original && draft.energyKcal === original.draft.energyKcal) { + if (original.totalEnergyKcal !== null) { + entry.totalEnergyKcal = original.totalEnergyKcal; + } + } else if (energy !== null) { + entry.totalEnergyKcal = energy; + } return { ok: true, entry }; } diff --git a/tests/integration/manual-workout-edit.test.ts b/tests/integration/manual-workout-edit.test.ts new file mode 100644 index 000000000..1abf71e2a --- /dev/null +++ b/tests/integration/manual-workout-edit.test.ts @@ -0,0 +1,192 @@ +/** + * Editing a hand-entered workout (#1162), end to end against real Postgres: + * the detail read the page opens the edit sheet from, the entry the form + * builds from it, and the batch route's overwrite of the `manual:` row. + * + * The overwrite replaces every overwritable column and nulls the ones a + * re-post leaves out. The form has no heart-rate or step fields, so an edit + * that did not carry them back would erase them; and the detail read's + * `avgHr` can be filled in from a same-session twin, which must not end up + * stored on the hand-entered row. + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; +import { + buildManualWorkoutEntry, + manualWorkoutOriginalFromRow, +} from "@/lib/workouts/manual-entry"; + +const USER_ID = "user-manual-workout-edit"; +const EXTERNAL_ID = "manual:0f9e8d7c-1111-4222-8333-444455556666"; + +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), +})); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + await getPrismaClient().user.create({ + data: { + id: USER_ID, + username: "manual-workout-edit", + email: "manual-workout-edit@example.test", + timezone: "Europe/Berlin", + }, + }); + const session = await getPrismaClient().session.create({ + data: { userId: USER_ID, expiresAt: new Date(Date.now() + 3_600_000) }, + }); + cookieJar.set("healthlog_session", session.id); +}); + +async function postWorkouts(workouts: unknown[]) { + const { POST } = await import("@/app/api/workouts/batch/route"); + const res = await POST( + new NextRequest("http://localhost/api/workouts/batch", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ workouts }), + }), + ); + expect(res.status).toBe(200); + return ( + (await res.json()) as { data: { entries: Array<{ status: string }> } } + ).data.entries; +} + +async function readDetail(id: string) { + const { GET } = await import("@/app/api/workouts/[id]/route"); + const res = await ( + GET as unknown as ( + r: NextRequest, + ctx: { params: Promise<{ id: string }> }, + ) => Promise + )(new NextRequest(`http://localhost/api/workouts/${id}?compact=1`), { + params: Promise.resolve({ id }), + }); + expect(res.status).toBe(200); + return ((await res.json()) as { data: Record }).data; +} + +describe("editing a hand-entered workout (#1162)", () => { + it("changes the edited field and keeps every field the form has no input for", async () => { + // A MANUAL row carrying heart rate and steps, as a client other than the + // web form can write it. + const [created] = await postWorkouts([ + { + sportType: "running", + startedAt: "2026-09-15T05:12:34.000Z", + endedAt: "2026-09-15T05:59:54.000Z", + source: "MANUAL", + externalId: EXTERNAL_ID, + totalDistanceM: 5000, + totalEnergyKcal: 410, + avgHeartRate: 142, + maxHeartRate: 171, + minHeartRate: 98, + stepCount: 6400, + elevationM: 42.5, + pauseDurationSec: 60, + }, + ]); + expect(created?.status).toBe("inserted"); + const row = await getPrismaClient().workout.findFirstOrThrow({ + where: { userId: USER_ID, externalId: EXTERNAL_ID }, + }); + + const detail = await readDetail(row.id); + expect(detail.storedAvgHr).toBe(142); + const original = manualWorkoutOriginalFromRow( + detail as unknown as Parameters[0], + { timezone: "Europe/Berlin", unitPreference: "imperial" }, + ); + const built = buildManualWorkoutEntry( + { ...original.draft, energyKcal: "455" }, + { + timezone: "Europe/Berlin", + unitPreference: "imperial", + externalId: detail.externalId as string, + now: new Date("2026-09-16T12:00:00.000Z"), + original, + }, + ); + if (!built.ok) throw new Error(JSON.stringify(built.errors)); + + const [edited] = await postWorkouts([built.entry]); + expect(edited?.status).toBe("updated"); + + const after = await getPrismaClient().workout.findUniqueOrThrow({ + where: { id: row.id }, + }); + expect(after.totalEnergyKcal).toBe(455); + expect(after.avgHeartRate).toBe(142); + expect(after.maxHeartRate).toBe(171); + expect(after.minHeartRate).toBe(98); + expect(after.stepCount).toBe(6400); + expect(after.elevationM).toBe(42.5); + expect(after.pauseDurationSec).toBe(60); + // Shown as 3.11 mi and left alone: the stored metres, not 5 005.1. + expect(after.totalDistanceM).toBe(5000); + expect(after.startedAt.toISOString()).toBe("2026-09-15T05:12:34.000Z"); + expect(after.endedAt.toISOString()).toBe("2026-09-15T05:59:54.000Z"); + expect( + await getPrismaClient().workout.count({ where: { userId: USER_ID } }), + ).toBe(1); + }); + + it("answers duplicate, and writes nothing, for an edit saved unchanged", async () => { + await postWorkouts([ + { + sportType: "cycling", + startedAt: "2026-09-15T06:00:00.000Z", + endedAt: "2026-09-15T07:30:00.000Z", + source: "MANUAL", + externalId: EXTERNAL_ID, + totalDistanceM: 32000, + }, + ]); + const row = await getPrismaClient().workout.findFirstOrThrow({ + where: { userId: USER_ID }, + }); + const detail = await readDetail(row.id); + const original = manualWorkoutOriginalFromRow( + detail as unknown as Parameters[0], + { timezone: "Europe/Berlin", unitPreference: "metric" }, + ); + const built = buildManualWorkoutEntry(original.draft, { + timezone: "Europe/Berlin", + unitPreference: "metric", + externalId: EXTERNAL_ID, + now: new Date("2026-09-16T12:00:00.000Z"), + original, + }); + if (!built.ok) throw new Error(JSON.stringify(built.errors)); + const [result] = await postWorkouts([built.entry]); + expect(result?.status).toBe("duplicate"); + }); +});