mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
1 parent
ba99551faa
commit
2731c46399
13 files changed
+717
-22
No files matched your search
@@ -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)",
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user