Merge pull request #1089 from MBombeck/feat/manual-workout-entry

Log a workout by hand
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-10-02 23:59:11 +02:00
commit 2473d52f19
31 files changed
+2579 -61

No files matched your search

@@ -255,6 +255,7 @@ describe("every surface that shows a module's data is owned by it", () => {
["overview:cycle-ring", "cycle"],
["capture:mood", "mood"],
["capture:medication", "medications"],
["capture:workout", "workouts"],
["trend:mood", "mood"],
["widget:medications", "medications"],
["correlation:MEDICATION_COMPLIANCE", "medications"],
+1
View File
@@ -123,6 +123,7 @@ const STATIC_SURFACE_MODULE = {
// ── Add menu ──
"capture:mood": "mood",
"capture:medication": "medications",
"capture:workout": "workouts",
// ── Dashboard widgets ──
// CORE widgets (weight, blood pressure, pulse, body fat, the vital-derived
+1
View File
@@ -357,6 +357,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"workout.batch.payload_too_large",
"workout.batch.source_not_permitted",
"workout.batch.too_large",
"workout.delete.synced_source",
],
"(unprefixed)": [
"about_me_conflict",
+30
View File
@@ -312,6 +312,36 @@ export const workoutPaths: NonNullable<ZodOpenApiObject["paths"]> = {
...stdResponses,
},
},
delete: {
tags: ["Measurements"],
summary: "Delete a workout entered by hand",
description:
"Removes a `MANUAL` workout outright, with its child rows, and any personal record that workout set; a silent detection pass then re-derives the previous best. A workout from any other source answers 409 (`workout.delete.synced_source`), because the next sync would write it back. Own record only: a cookie session or a wildcard token. Another user's row answers 404.",
requestParams: { path: z.object({ id: z.string() }) },
responses: {
"200": {
description: "Deleted.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({ deleted: z.literal(true) }),
"DeleteWorkoutResponse",
),
},
},
},
"404": {
description: "Workout not found (or owned by another user).",
content: { "application/json": { schema: errorEnvelope } },
},
"409": {
description:
"The workout was synced from a device or service, not entered by hand.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
},
"/api/workouts/batch": {
post: {
@@ -0,0 +1,259 @@
import { describe, expect, it } from "vitest";
import {
buildManualWorkoutEntry,
canLogWorkout,
emptyManualWorkoutDraft,
newManualWorkoutExternalId,
wallClockNow,
type ManualWorkoutContext,
type ManualWorkoutDraft,
} from "@/lib/workouts/manual-entry";
import { createBatchWorkoutSchema } from "@/lib/validations/workout";
const NOW = new Date("2026-09-15T12:00:00Z");
const ID = "manual:5b0f3c1e-1111-4222-8333-444455556666";
const CTX: ManualWorkoutContext = {
timezone: "Europe/Berlin",
unitPreference: "metric",
externalId: ID,
now: NOW,
};
function draft(over: Partial<ManualWorkoutDraft> = {}): ManualWorkoutDraft {
return {
sportType: "running",
// 08:00 in Berlin (UTC+2 in September) = 06:00Z.
start: "2026-09-15T08:00",
hours: "0",
minutes: "45",
distance: "",
energyKcal: "",
...over,
};
}
function errorsOf(over: Partial<ManualWorkoutDraft>, ctx = CTX) {
const result = buildManualWorkoutEntry(draft(over), ctx);
if (result.ok) throw new Error("expected the draft to be refused");
return result.errors;
}
describe("buildManualWorkoutEntry — the payload", () => {
it("builds exactly the batch entry: MANUAL, the form's external id, start + duration", () => {
const result = buildManualWorkoutEntry(draft(), CTX);
expect(result).toEqual({
ok: true,
entry: {
sportType: "running",
startedAt: "2026-09-15T06:00:00.000Z",
endedAt: "2026-09-15T06:45:00.000Z",
source: "MANUAL",
externalId: ID,
},
});
});
it("sends the same external id on every submit of one form", () => {
const a = buildManualWorkoutEntry(draft(), CTX);
const b = buildManualWorkoutEntry(draft(), { ...CTX, now: new Date() });
expect(a.ok && b.ok).toBe(true);
if (a.ok && b.ok) expect(a.entry.externalId).toBe(b.entry.externalId);
});
it("is accepted by the batch route's own schema", () => {
const result = buildManualWorkoutEntry(
draft({ distance: "10,5", energyKcal: "640" }),
CTX,
);
if (!result.ok) throw new Error("expected ok");
const parsed = createBatchWorkoutSchema.safeParse({
workouts: [result.entry],
});
expect(parsed.success).toBe(true);
if (parsed.success) expect(parsed.data.workouts[0]!.source).toBe("MANUAL");
});
it("reads the start in the profile zone, not the device's", () => {
const result = buildManualWorkoutEntry(
draft({ start: "2026-09-15T05:00" }),
{
...CTX,
timezone: "America/New_York",
},
);
// 05:00 in New York (UTC-4 in September) = 09:00Z.
expect(result.ok && result.entry.startedAt).toBe(
"2026-09-15T09:00:00.000Z",
);
});
it("stores a metric distance in metres and energy as typed", () => {
const result = buildManualWorkoutEntry(
draft({ distance: "5,25", energyKcal: "312.5" }),
CTX,
);
expect(result.ok && result.entry.totalDistanceM).toBe(5250);
expect(result.ok && result.entry.totalEnergyKcal).toBe(312.5);
});
it("converts an imperial distance from miles to metres", () => {
const result = buildManualWorkoutEntry(draft({ distance: "3.1" }), {
...CTX,
unitPreference: "imperial",
});
// 3.1 mi = 4988.97 m, stored to a tenth.
expect(result.ok && result.entry.totalDistanceM).toBe(4989);
});
it("leaves distance and energy out when they were not entered", () => {
const result = buildManualWorkoutEntry(draft(), CTX);
expect(result.ok && "totalDistanceM" in result.entry).toBe(false);
expect(result.ok && "totalEnergyKcal" in result.entry).toBe(false);
});
it("accepts a session of exactly 24 hours", () => {
const result = buildManualWorkoutEntry(
draft({ start: "2026-09-14T08:00", hours: "24", minutes: "" }),
CTX,
);
expect(result.ok && result.entry.endedAt).toBe("2026-09-15T06:00:00.000Z");
});
});
describe("buildManualWorkoutEntry — validation", () => {
it("asks for a sport", () => {
expect(errorsOf({ sportType: "" }).sportType).toBe(
"insights.workouts.manual.errors.sportRequired",
);
});
it("refuses a start in the future", () => {
expect(errorsOf({ start: "2026-09-15T15:00" }).start).toBe(
"insights.workouts.manual.errors.startInFuture",
);
});
it("tolerates the minute the form was opened in", () => {
// 14:00:30 Berlin is 30 s past `now`: inside the slack.
const result = buildManualWorkoutEntry(
draft({ start: "2026-09-15T14:00" }),
{
...CTX,
now: new Date("2026-09-15T11:59:30Z"),
},
);
expect(result.ok).toBe(true);
});
it("asks for a start", () => {
expect(errorsOf({ start: "" }).start).toBe(
"insights.workouts.manual.errors.startRequired",
);
});
it("refuses a zero duration", () => {
expect(errorsOf({ hours: "", minutes: "" }).duration).toBe(
"insights.workouts.manual.errors.durationRequired",
);
expect(errorsOf({ hours: "0", minutes: "0" }).duration).toBe(
"insights.workouts.manual.errors.durationRequired",
);
});
it("refuses more than 24 hours", () => {
expect(errorsOf({ hours: "24", minutes: "1" }).duration).toBe(
"insights.workouts.manual.errors.durationTooLong",
);
});
it("refuses minutes past 59 and anything that is not a whole number", () => {
for (const over of [
{ minutes: "60" },
{ minutes: "-5" },
{ hours: "1.5" },
{ hours: "abc" },
]) {
expect(errorsOf(over).duration, JSON.stringify(over)).toBe(
"insights.workouts.manual.errors.durationInvalid",
);
}
});
it("refuses a negative, garbled or impossible distance", () => {
for (const distance of ["-1", "5km", "1000.1"]) {
expect(errorsOf({ distance }).distance, distance).toBe(
"insights.workouts.manual.errors.distanceInvalid",
);
}
// The ceiling is in metres, so it binds in miles too.
expect(
errorsOf({ distance: "622" }, { ...CTX, unitPreference: "imperial" })
.distance,
).toBe("insights.workouts.manual.errors.distanceInvalid");
});
it("accepts a zero distance", () => {
const result = buildManualWorkoutEntry(draft({ distance: "0" }), CTX);
expect(result.ok && result.entry.totalDistanceM).toBe(0);
});
it("refuses negative or implausible energy", () => {
for (const energyKcal of ["-10", "20001", "lots"]) {
expect(errorsOf({ energyKcal }).energyKcal, energyKcal).toBe(
"insights.workouts.manual.errors.energyInvalid",
);
}
});
it("reports every refused field at once", () => {
const errors = errorsOf({ sportType: "", minutes: "", hours: "" });
expect(Object.keys(errors).sort()).toEqual(["duration", "sportType"]);
});
});
describe("the form's defaults", () => {
it("opens on the current minute in the profile zone", () => {
expect(wallClockNow(NOW, "Europe/Berlin")).toBe("2026-09-15T14:00");
expect(emptyManualWorkoutDraft(NOW, "Asia/Seoul")).toMatchObject({
sportType: "",
start: "2026-09-15T21:00",
hours: "",
minutes: "",
});
});
it("mints a distinct, stable-shaped external id per form", () => {
const a = newManualWorkoutExternalId();
const b = newManualWorkoutExternalId();
expect(a).toMatch(/^manual:[0-9a-f-]{36}$/);
expect(a).not.toBe(b);
});
});
describe("canLogWorkout — who is offered the entry", () => {
const OWN = { inSharedRecord: false };
const SHARED = { inSharedRecord: true };
it("offers it in one's own record with the workouts module on", () => {
expect(canLogWorkout(OWN, { workouts: true })).toBe(true);
});
it("offers it while the module map is still loading", () => {
expect(canLogWorkout(OWN, undefined)).toBe(true);
expect(canLogWorkout(OWN, null)).toBe(true);
});
it("withholds it when the workouts module is off", () => {
expect(canLogWorkout(OWN, { workouts: false })).toBe(false);
});
it("does not follow any other module", () => {
expect(canLogWorkout(OWN, { mood: false, recovery: false })).toBe(true);
});
it("withholds it inside somebody else's record", () => {
expect(canLogWorkout(SHARED, { workouts: true })).toBe(false);
});
});
+316
View File
@@ -0,0 +1,316 @@
/**
* A workout entered by hand: the form's draft, its validation, and the one
* batch entry it becomes.
*
* HealthLog records workouts; it does not plan them. So the hand-entered
* shape is the session as a health record sees it and nothing more: what,
* when, how long, and optionally how far and how much energy. No sets, no
* exercises, no heart rate typed in from memory.
*
* The entry is written through `POST /api/workouts/batch`, the same route the
* phone syncs through, as `source: "MANUAL"` with an `externalId` minted once
* per opened form. That id is what makes a double submit (a second tap, a
* retried request) a `duplicate` instead of a second row: the route's
* `(userId, source, externalId)` key catches it.
*
* Pure, no React and no fetch, so the rules are pinned without a browser.
* Error values are i18n keys; the form translates them.
*/
import { z } from "zod/v4";
import {
getQuantityTransform,
invertDisplayTransform,
type UnitPreference,
} from "@/lib/measurements/display-transform";
import { wallClockInTz, zonedWallClockToUtc } from "@/lib/tz/wall-clock";
import {
workoutSportTypeEnum,
type WorkoutSportType,
} from "@/lib/validations/workout";
import { randomId } from "@/lib/random-id";
import { isSurfaceVisible, type SurfaceModuleMap } from "@/lib/modules/surface";
/** One session is at most a day long. */
export const MANUAL_WORKOUT_MAX_DURATION_SEC = 24 * 60 * 60;
/** The batch schema's own ceiling: 1000 km. */
export const MANUAL_WORKOUT_MAX_DISTANCE_M = 1_000_000;
/** A day of hard endurance work stays well under this. */
export const MANUAL_WORKOUT_MAX_ENERGY_KCAL = 20_000;
/**
* Slack on "not in the future". The form's default start is the minute the
* sheet opened, and the clock keeps moving while the person types.
*/
const FUTURE_TOLERANCE_MS = 60_000;
export interface ManualWorkoutDraft {
/** Empty until the person picks one. */
sportType: WorkoutSportType | "";
/** Wall clock in the profile timezone, `yyyy-MM-ddTHH:mm`. */
start: string;
hours: string;
minutes: string;
/** In the reader's unit (km or mi). Empty means not recorded. */
distance: string;
/** Kilocalories. Empty means not recorded. */
energyKcal: string;
}
export type ManualWorkoutField =
"sportType" | "start" | "duration" | "distance" | "energyKcal";
/** The one entry of the `POST /api/workouts/batch` body this form sends. */
export interface ManualWorkoutEntry {
sportType: WorkoutSportType;
startedAt: string;
endedAt: string;
source: "MANUAL";
externalId: string;
totalDistanceM?: number;
totalEnergyKcal?: number;
}
export interface ManualWorkoutContext {
/** The profile timezone the start is read in. */
timezone: string;
unitPreference: UnitPreference;
/** The id this form instance sends with every submit. */
externalId: string;
now: Date;
}
export type ManualWorkoutResult =
| { ok: true; entry: ManualWorkoutEntry }
| { ok: false; errors: Partial<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);
}
/** A fresh id for one opened form. The prefix says where the row came from. */
export function newManualWorkoutExternalId(): string {
return `manual:${randomId()}`;
}
/** `now` as the wall clock the start field shows, `yyyy-MM-ddTHH:mm`. */
export function wallClockNow(now: Date, timezone: string): string {
const p = wallClockInTz(now, timezone);
const pad = (n: number) => String(n).padStart(2, "0");
return `${p.year}-${pad(p.month)}-${pad(p.day)}T${pad(p.hour)}:${pad(p.minute)}`;
}
export function emptyManualWorkoutDraft(
now: Date,
timezone: string,
): ManualWorkoutDraft {
return {
sportType: "",
start: wallClockNow(now, timezone),
hours: "",
minutes: "",
distance: "",
energyKcal: "",
};
}
const WALL_CLOCK = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/;
function startToUtc(start: string, timezone: string): Date | null {
const m = WALL_CLOCK.exec(start);
if (!m) return null;
const [year, month, day, hour, minute] = m.slice(1).map(Number) as [
number,
number,
number,
number,
number,
];
if (month < 1 || month > 12 || day < 1 || day > 31) return null;
if (hour > 23 || minute > 59) return null;
const at = zonedWallClockToUtc({ year, month, day, hour, minute }, timezone);
return Number.isNaN(at.getTime()) ? null : at;
}
/**
* A typed number, or `null` for an empty field, or `NaN` for garbage.
* Accepts a decimal comma, which is what half the locales type.
*/
function parseDecimal(raw: string): number | null {
const trimmed = raw.trim();
if (trimmed === "") return null;
if (!/^\d+([.,]\d+)?$/.test(trimmed)) return Number.NaN;
return Number(trimmed.replace(",", "."));
}
function parseWhole(raw: string): number | null {
const trimmed = raw.trim();
if (trimmed === "") return null;
if (!/^\d+$/.test(trimmed)) return Number.NaN;
return Number(trimmed);
}
/**
* The draft, checked. Built per call because "not in the future" and the
* unit conversion depend on the context.
*/
function manualWorkoutSchema(ctx: ManualWorkoutContext) {
return z
.object({
sportType: z.string(),
start: z.string(),
hours: z.string(),
minutes: z.string(),
distance: z.string(),
energyKcal: z.string(),
})
.superRefine((d, issue) => {
if (!workoutSportTypeEnum.safeParse(d.sportType).success) {
issue.addIssue({
code: "custom",
path: ["sportType"],
message: `${ERR}.sportRequired`,
});
}
const startedAt = startToUtc(d.start, ctx.timezone);
if (startedAt === null) {
issue.addIssue({
code: "custom",
path: ["start"],
message: `${ERR}.startRequired`,
});
} else if (
startedAt.getTime() >
ctx.now.getTime() + FUTURE_TOLERANCE_MS
) {
issue.addIssue({
code: "custom",
path: ["start"],
message: `${ERR}.startInFuture`,
});
}
const hours = parseWhole(d.hours);
const minutes = parseWhole(d.minutes);
if (
Number.isNaN(hours) ||
Number.isNaN(minutes) ||
(minutes !== null && minutes > 59)
) {
issue.addIssue({
code: "custom",
path: ["duration"],
message: `${ERR}.durationInvalid`,
});
} else {
const sec = ((hours ?? 0) * 60 + (minutes ?? 0)) * 60;
if (sec <= 0) {
issue.addIssue({
code: "custom",
path: ["duration"],
message: `${ERR}.durationRequired`,
});
} else if (sec > MANUAL_WORKOUT_MAX_DURATION_SEC) {
issue.addIssue({
code: "custom",
path: ["duration"],
message: `${ERR}.durationTooLong`,
});
}
}
const distance = parseDecimal(d.distance);
if (
distance !== null &&
(Number.isNaN(distance) ||
toMetres(distance, ctx.unitPreference) >
MANUAL_WORKOUT_MAX_DISTANCE_M)
) {
issue.addIssue({
code: "custom",
path: ["distance"],
message: `${ERR}.distanceInvalid`,
});
}
const energy = parseDecimal(d.energyKcal);
if (
energy !== null &&
(Number.isNaN(energy) || energy > MANUAL_WORKOUT_MAX_ENERGY_KCAL)
) {
issue.addIssue({
code: "custom",
path: ["energyKcal"],
message: `${ERR}.energyInvalid`,
});
}
});
}
/** A distance in the reader's unit, in metres to a tenth. */
function toMetres(display: number, preference: UnitPreference): number {
const metres = invertDisplayTransform(
display,
getQuantityTransform("distance", preference),
);
return Math.round(metres * 10) / 10;
}
/**
* Validate the draft and build the batch entry from it, field by field.
* Returns the first error per field when it does not validate.
*/
export function buildManualWorkoutEntry(
draft: ManualWorkoutDraft,
ctx: ManualWorkoutContext,
): ManualWorkoutResult {
const parsed = manualWorkoutSchema(ctx).safeParse(draft);
if (!parsed.success) {
const errors: Partial<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 startedAt = startToUtc(draft.start, ctx.timezone)!;
const durationSec =
((parseWhole(draft.hours) ?? 0) * 60 + (parseWhole(draft.minutes) ?? 0)) *
60;
const distance = parseDecimal(draft.distance);
const energy = parseDecimal(draft.energyKcal);
const entry: ManualWorkoutEntry = {
sportType: draft.sportType as WorkoutSportType,
startedAt: startedAt.toISOString(),
endedAt: new Date(startedAt.getTime() + durationSec * 1000).toISOString(),
source: "MANUAL",
externalId: ctx.externalId,
};
if (distance !== null) {
entry.totalDistanceM = toMetres(distance, ctx.unitPreference);
}
if (energy !== null) entry.totalEnergyKcal = energy;
return { ok: true, entry };
}