feat(workouts): edit a hand-entered workout from its detail page

A workout logged by hand could only be deleted and logged again. The
detail page now offers an edit control beside delete for a MANUAL workout
carrying the form's `manual:` id, in one's own record. It opens the log
sheet on the stored values and saves by re-posting that id through
`POST /api/workouts/batch`, which already applies such a re-post as an
overwrite of that one row. No new route.

The overwrite nulls every overwritable column a re-post leaves out, and
the form has no fields for heart rate, steps, elevation or pauses. The
edit therefore sends those back as stored. A field left as shown sends the
stored value rather than one re-parsed from its display, so saving an
untouched edit keeps the start's seconds and a distance shown in miles at
its exact metres, and answers `duplicate`.

The detail read's `avgHr` / `maxHr` can come from a same-session twin;
the read now also serves the row's own `storedAvgHr` / `storedMaxHr`,
which is what the edit sends back.
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 09:28:56 +02:00
1 parent ba99551faa
commit 2731c46399
13 files changed
+717 -22

No files matched your search

+16
View File
@@ -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
@@ -91,9 +91,6 @@ const RELEASE_PENDING_PREFIXES: Record<string, string> = {
"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)",
};
+6
View File
@@ -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,
+15 -3
View File
@@ -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 ? <DeleteWorkoutButton workout={data} /> : null
data && !canonicalHref ? (
<div className="flex items-center gap-1">
<EditWorkoutButton
workout={{
...data,
storedAvgHr: data.storedAvgHr ?? null,
storedMaxHr: data.storedMaxHr ?? null,
}}
/>
<DeleteWorkoutButton workout={data} />
</div>
) : null
}
backLink={
<BackLink
@@ -47,7 +47,10 @@ import {
canDeleteWorkout,
deleteManualWorkout,
} from "../delete-workout-button";
import type { ManualWorkoutEntry } from "@/lib/workouts/manual-entry";
import {
manualWorkoutOriginalFromRow,
type ManualWorkoutEntry,
} from "@/lib/workouts/manual-entry";
const ENTRY: ManualWorkoutEntry = {
sportType: "cycling",
@@ -251,3 +254,39 @@ describe("delete — a hand-entered workout in one's own record only", () => {
});
});
});
describe("<ManualWorkoutForm> — 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(
<ManualWorkoutForm edit={{ externalId: ENTRY.externalId, original }} />,
);
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(<ManualWorkoutForm />);
expect(html).not.toContain('value="640"');
});
});
@@ -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 (
<>
<Button
type="button"
variant="ghost"
size="icon"
data-slot="workout-edit"
aria-label={t("insights.workouts.manual.editAction")}
title={t("insights.workouts.manual.editTitle")}
onClick={() => setOpen(true)}
className={cn(
"text-muted-foreground hover:text-foreground relative size-10",
"before:absolute before:-inset-1.5 before:content-['']",
)}
>
<Pencil className="size-4" aria-hidden="true" />
</Button>
{/* The sheet mounts its form only while open, so each opening starts
from the values the page shows at that moment. */}
<ManualWorkoutSheet
open={open}
onOpenChange={setOpen}
edit={{ externalId: workout.externalId, original }}
/>
</>
);
}
@@ -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<ManualWorkoutDraft>(() =>
emptyManualWorkoutDraft(),
// An edit sends the stored row's own id instead.
const [externalId] = useState(
() => edit?.externalId ?? newManualWorkoutExternalId(),
);
const [initialDraft] = useState<ManualWorkoutDraft>(
() => edit?.original.draft ?? emptyManualWorkoutDraft(),
);
const [draft, setDraft] = useState<ManualWorkoutDraft>(initialDraft);
const dirty = (
Object.keys(initialDraft) as Array<keyof ManualWorkoutDraft>
).some((key) => draft[key] !== initialDraft[key]);
useEffect(() => {
onDirtyChange?.(dirty);
}, [dirty, onDirtyChange]);
const [errors, setErrors] = useState<
Partial<Record<ManualWorkoutField, string>>
>({});
@@ -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 ? (
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
) : edit ? (
<Check className="size-4" />
) : (
<Plus className="size-4" />
)}
@@ -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<HTMLDivElement | null>(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({
<ResponsiveSheet
open={open}
onOpenChange={handleOpenChange}
title={t("insights.workouts.manual.sheetTitle")}
title={t(
edit
? "insights.workouts.manual.editTitle"
: "insights.workouts.manual.sheetTitle",
)}
description={t("insights.workouts.manual.sheetDescription")}
footer={<div ref={setFooterEl} className="flex w-full" />}
>
@@ -58,6 +75,8 @@ export function ManualWorkoutSheet({
onSuccess={() => onOpenChange(false)}
onCancel={() => onOpenChange(false)}
footerSlot={footerEl}
edit={edit}
onDirtyChange={edit ? handleDirtyChange : undefined}
/>
)}
</ResponsiveSheet>
+7
View File
@@ -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;
+16
View File
@@ -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(),
@@ -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<ManualWorkoutDraft>,
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);
});
});
+151 -6
View File
@@ -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 };
}
@@ -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<Response>
)(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<string, unknown> }).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<typeof manualWorkoutOriginalFromRow>[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<typeof manualWorkoutOriginalFromRow>[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");
});
});