Files
HealthLog/src/lib/workouts/manual-entry.ts
T
Marc-André Bombeck 2731c46399 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.
2026-10-08 09:28:56 +02:00

477 lines
16 KiB
TypeScript

/**
* A workout entered by hand: the form's draft, its validation, and the one
* batch entry it becomes.
*
* HealthLog records workouts; it does not plan them. So the hand-entered
* shape is the session as a health record sees it and nothing more: what,
* when, how long, and optionally how far and how much energy. No sets, no
* exercises, no heart rate typed in from memory.
*
* The entry is written through `POST /api/workouts/batch`, the same route the
* phone syncs through, as `source: "MANUAL"` with an `externalId` minted once
* per opened form. That id is what makes a double submit (a second tap, a
* retried request) a `duplicate` instead of a second row: the route's
* `(userId, source, externalId)` key catches it.
*
* Pure, no React and no fetch, so the rules are pinned without a browser.
* Error values are i18n keys; the form translates them.
*/
import { z } from "zod/v4";
import {
applyDisplayTransform,
getQuantityTransform,
invertDisplayTransform,
type UnitPreference,
} from "@/lib/measurements/display-transform";
import { wallClockInTz, zonedWallClockToUtc } from "@/lib/tz/wall-clock";
import {
workoutSportTypeEnum,
type WorkoutSportType,
} from "@/lib/validations/workout";
import { randomId } from "@/lib/random-id";
import { isSurfaceVisible, type SurfaceModuleMap } from "@/lib/modules/surface";
/** One session is at most a day long. */
export const MANUAL_WORKOUT_MAX_DURATION_SEC = 24 * 60 * 60;
/** The batch schema's own ceiling: 1000 km. */
export const MANUAL_WORKOUT_MAX_DISTANCE_M = 1_000_000;
/** A day of hard endurance work stays well under this. */
export const MANUAL_WORKOUT_MAX_ENERGY_KCAL = 20_000;
/**
* Slack on "not in the future". The form's default start is the minute the
* sheet opened, and the clock keeps moving while the person types.
*/
const FUTURE_TOLERANCE_MS = 60_000;
export interface ManualWorkoutDraft {
/** Empty until the person picks one. */
sportType: WorkoutSportType | "";
/** Wall clock in the profile timezone, `yyyy-MM-ddTHH:mm`. */
start: string;
hours: string;
minutes: string;
/** In the reader's unit (km or mi). Empty means not recorded. */
distance: string;
/** Kilocalories. Empty means not recorded. */
energyKcal: string;
}
export type ManualWorkoutField =
"sportType" | "start" | "duration" | "distance" | "energyKcal";
/**
* The 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 extends ManualWorkoutCarried {
sportType: WorkoutSportType;
startedAt: string;
endedAt: string;
source: "MANUAL";
externalId: string;
totalDistanceM?: number;
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;
unitPreference: UnitPreference;
/** 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 =
| { ok: true; entry: ManualWorkoutEntry }
| { ok: false; errors: Partial<Record<ManualWorkoutField, string>> };
const ERR = "insights.workouts.manual.errors";
/**
* Whether "Log workout" is offered: in one's own record, with the workouts
* module on.
*
* Own record only because the route behind it is: the batch ingest resolves
* the caller (`requireAuth`), and a delegate acting on somebody else's record
* would be refused, so no section of a grant can admit it. The module half is
* the `capture:workout` surface; an absent module map (the account still
* loading) offers it, the gate's default-on contract.
*
* The workouts page asks this; the dashboard add menu and the capture picker
* ask `visibleCaptureKinds`, which applies the same two terms to the
* `workout` kind.
*/
export function canLogWorkout(
caps: { inSharedRecord: boolean },
modules?: SurfaceModuleMap | null,
): boolean {
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()}`;
}
/** `now` as the wall clock the start field shows, `yyyy-MM-ddTHH:mm`. */
export function wallClockNow(now: Date, timezone: string): string {
const p = wallClockInTz(now, timezone);
const pad = (n: number) => String(n).padStart(2, "0");
return `${p.year}-${pad(p.month)}-${pad(p.day)}T${pad(p.hour)}:${pad(p.minute)}`;
}
export function emptyManualWorkoutDraft(): ManualWorkoutDraft {
return {
sportType: "",
// Blank on purpose (#1085): a blank start means "it just ended", so the
// workout is saved as starting `duration` before now.
start: "",
hours: "",
minutes: "",
distance: "",
energyKcal: "",
};
}
const WALL_CLOCK = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/;
function startToUtc(start: string, timezone: string): Date | null {
const m = WALL_CLOCK.exec(start);
if (!m) return null;
const [year, month, day, hour, minute] = m.slice(1).map(Number) as [
number,
number,
number,
number,
number,
];
if (month < 1 || month > 12 || day < 1 || day > 31) return null;
if (hour > 23 || minute > 59) return null;
const at = zonedWallClockToUtc({ year, month, day, hour, minute }, timezone);
return Number.isNaN(at.getTime()) ? null : at;
}
/**
* A typed number, or `null` for an empty field, or `NaN` for garbage.
* Accepts a decimal comma, which is what half the locales type.
*/
function parseDecimal(raw: string): number | null {
const trimmed = raw.trim();
if (trimmed === "") return null;
if (!/^\d+([.,]\d+)?$/.test(trimmed)) return Number.NaN;
return Number(trimmed.replace(",", "."));
}
function parseWhole(raw: string): number | null {
const trimmed = raw.trim();
if (trimmed === "") return null;
if (!/^\d+$/.test(trimmed)) return Number.NaN;
return Number(trimmed);
}
/**
* The draft, checked. Built per call because "not in the future" and the
* unit conversion depend on the context.
*/
function manualWorkoutSchema(ctx: ManualWorkoutContext) {
return z
.object({
sportType: z.string(),
start: z.string(),
hours: z.string(),
minutes: z.string(),
distance: z.string(),
energyKcal: z.string(),
})
.superRefine((d, issue) => {
if (!workoutSportTypeEnum.safeParse(d.sportType).success) {
issue.addIssue({
code: "custom",
path: ["sportType"],
message: `${ERR}.sportRequired`,
});
}
const startBlank = d.start.trim() === "";
const startedAt = startBlank ? null : startToUtc(d.start, ctx.timezone);
if (!startBlank && startedAt === null) {
issue.addIssue({
code: "custom",
path: ["start"],
message: `${ERR}.startRequired`,
});
} else if (
startedAt !== null &&
startedAt.getTime() > ctx.now.getTime() + FUTURE_TOLERANCE_MS
) {
issue.addIssue({
code: "custom",
path: ["start"],
message: `${ERR}.startInFuture`,
});
}
const hours = parseWhole(d.hours);
const minutes = parseWhole(d.minutes);
if (
Number.isNaN(hours) ||
Number.isNaN(minutes) ||
(minutes !== null && minutes > 59)
) {
issue.addIssue({
code: "custom",
path: ["duration"],
message: `${ERR}.durationInvalid`,
});
} else {
const sec = ((hours ?? 0) * 60 + (minutes ?? 0)) * 60;
if (sec <= 0) {
issue.addIssue({
code: "custom",
path: ["duration"],
message: `${ERR}.durationRequired`,
});
} else if (sec > MANUAL_WORKOUT_MAX_DURATION_SEC) {
issue.addIssue({
code: "custom",
path: ["duration"],
message: `${ERR}.durationTooLong`,
});
} else if (
// Only a start the person entered can push the end past now; a
// blank start is read as "now minus the duration".
startedAt !== null &&
startedAt.getTime() + sec * 1000 >
ctx.now.getTime() + FUTURE_TOLERANCE_MS
) {
issue.addIssue({
code: "custom",
path: ["duration"],
message: `${ERR}.endInFuture`,
});
}
}
const distance = parseDecimal(d.distance);
if (
distance !== null &&
(Number.isNaN(distance) ||
toMetres(distance, ctx.unitPreference) >
MANUAL_WORKOUT_MAX_DISTANCE_M)
) {
issue.addIssue({
code: "custom",
path: ["distance"],
message: `${ERR}.distanceInvalid`,
});
}
const energy = parseDecimal(d.energyKcal);
if (
energy !== null &&
(Number.isNaN(energy) || energy > MANUAL_WORKOUT_MAX_ENERGY_KCAL)
) {
issue.addIssue({
code: "custom",
path: ["energyKcal"],
message: `${ERR}.energyInvalid`,
});
}
});
}
/** A distance in the reader's unit, in metres to a tenth. */
function toMetres(display: number, preference: UnitPreference): number {
const metres = invertDisplayTransform(
display,
getQuantityTransform("distance", preference),
);
return Math.round(metres * 10) / 10;
}
/**
* Validate the draft and build the batch entry from it, field by field.
* Returns the first error per field when it does not validate.
*/
export function buildManualWorkoutEntry(
draft: ManualWorkoutDraft,
ctx: ManualWorkoutContext,
): ManualWorkoutResult {
const parsed = manualWorkoutSchema(ctx).safeParse(draft);
if (!parsed.success) {
const errors: Partial<Record<ManualWorkoutField, string>> = {};
for (const issue of parsed.error.issues) {
const field = issue.path[0] as ManualWorkoutField;
errors[field] ??= issue.message;
}
return { ok: false, errors };
}
// Every branch below was proven by the schema above.
const original = ctx.original;
const durationSec =
((parseWhole(draft.hours) ?? 0) * 60 + (parseWhole(draft.minutes) ?? 0)) *
60;
// 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: 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 (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 (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 };
}