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

@@ -380,6 +380,13 @@ const REGISTRY: DestructiveEntry[] = [
recovery: "tombstoned-no-restore",
confirm: ["AlertDialog"],
},
{
file: "components/workouts/delete-workout-button.tsx",
destroys:
"one workout entered by hand, with the personal records it set; synced workouts are refused by the route",
recovery: "permanent",
confirm: ["AlertDialog"],
},
// ── Visits and the address book ─────────────────────────────────────────
{
+109 -1
View File
@@ -21,13 +21,21 @@
* route geometry); absent for manual entries.
* - Ownership-gated: a row owned by another user resolves as 404 so
* the existence channel never leaks.
*
* `DELETE /api/workouts/{id}` removes a workout entered by hand. Only a
* `MANUAL` row: a synced workout would come back with the next sync, so
* deleting it here would be a promise the record cannot keep. See the
* handler below.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { enqueuePrDetection } from "@/lib/jobs/pr-detection";
import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
@@ -357,3 +365,103 @@ export const GET = apiHandler(
});
},
);
/**
* `DELETE /api/workouts/{id}` — remove a workout that was entered by hand.
*
* - Own record only: `requireAuth()` with no scope admits a cookie session or
* a wildcard token and refuses an acting-account carrier, the same arm the
* detail read above uses. A narrow token is refused.
* - Ownership: another user's row answers 404, like the read.
* - `MANUAL` only. Every other source is a sync, and the next sync would
* write the row back, so it answers 409 instead of pretending.
* - Not module-gated: deleting is cleanup of the person's own rows, which
* the data layer allows whatever the module says
* (`module-route-gate-inventory.test.ts`).
*
* The workout table carries no tombstone, so the row is removed outright;
* route, samples, insight and claim rows cascade with it. A personal record
* the workout set would otherwise outlive it (the detector only ever raises
* a stored best), so the records keyed on this workout go in the same
* transaction and a silent detection pass re-derives the honest best.
*/
export const DELETE = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const row = await prisma.workout.findUnique({
where: { id },
select: {
id: true,
userId: true,
source: true,
externalId: true,
startedAt: true,
sportType: true,
},
});
if (!row || row.userId !== user.id) {
return apiError("Workout not found", 404);
}
if (row.source !== "MANUAL") {
annotate({
action: {
name: "workout.delete",
entity_type: "workout",
entity_id: id,
},
meta: { outcome: "synced_source", source: row.source },
});
return apiError(
"Only a workout entered by hand can be deleted here",
409,
{ errorCode: "workout.delete.synced_source" },
);
}
const removedRecords = await prisma.$transaction(async (tx) => {
const records = await tx.personalRecord.deleteMany({
where: {
userId: user.id,
metricSlot: { not: null },
source: "MANUAL",
...(row.externalId !== null
? { externalId: row.externalId }
: { externalId: null, achievedAt: row.startedAt }),
},
});
await tx.workout.delete({ where: { id: row.id } });
return records.count;
});
await auditLog("workout.delete", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
workoutId: id,
sportType: row.sportType,
personalRecordsRemoved: removedRecords,
},
});
annotate({
action: { name: "workout.delete", entity_type: "workout", entity_id: id },
meta: { outcome: "deleted", personalRecordsRemoved: removedRecords },
});
invalidateUserMeasurements(user.id, { evict: true });
if (removedRecords > 0) {
try {
await enqueuePrDetection(user.id, { silent: true });
} catch (err) {
annotate({
action: { name: "personal_records.detection_enqueue_failed" },
meta: { error: err instanceof Error ? err.message : String(err) },
});
}
}
return apiSuccess({ deleted: true });
},
);
@@ -0,0 +1,189 @@
/**
* Unit suite for `DELETE /api/workouts/{id}`.
*
* Pins:
* - a workout entered by hand is removed, with the personal records keyed
* on it, and a silent detection pass re-derives the previous best;
* - a synced workout answers 409 and nothing is touched, because the next
* sync would write it back;
* - another user's row and a missing row answer 404;
* - no session answers 401.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
const tx = {
personalRecord: { deleteMany: vi.fn() },
workout: { delete: vi.fn() },
};
vi.mock("@/lib/db", () => ({
prisma: {
workout: { findUnique: vi.fn() },
$transaction: vi.fn(async (fn: (client: typeof tx) => unknown) => fn(tx)),
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserMeasurements: vi.fn(),
}));
vi.mock("@/lib/jobs/pr-detection", () => ({
enqueuePrDetection: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { DELETE } from "../[id]/route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
import { enqueuePrDetection } from "@/lib/jobs/pr-detection";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
const MANUAL_ROW = {
id: "w-1",
userId: "user-1",
source: "MANUAL" as const,
externalId: "manual:5b0f3c1e-1111-4222-8333-444455556666",
startedAt: new Date("2026-09-01T07:00:00Z"),
sportType: "running",
};
function del(id = "w-1") {
return DELETE(
new NextRequest(`http://localhost/api/workouts/${id}`, {
method: "DELETE",
}),
{ params: Promise.resolve({ id }) },
);
}
describe("DELETE /api/workouts/{id}", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
tx.personalRecord.deleteMany.mockResolvedValue({ count: 0 });
tx.workout.delete.mockResolvedValue({ id: "w-1" });
});
it("removes a workout entered by hand", async () => {
vi.mocked(prisma.workout.findUnique).mockResolvedValue(MANUAL_ROW as never);
const res = await del();
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ data: { deleted: true }, error: null });
expect(tx.workout.delete).toHaveBeenCalledWith({ where: { id: "w-1" } });
expect(auditLog).toHaveBeenCalledWith(
"workout.delete",
expect.objectContaining({ userId: "user-1" }),
);
expect(invalidateUserMeasurements).toHaveBeenCalledWith("user-1", {
evict: true,
});
});
it("takes the personal records the workout set with it and re-derives the best", async () => {
vi.mocked(prisma.workout.findUnique).mockResolvedValue(MANUAL_ROW as never);
tx.personalRecord.deleteMany.mockResolvedValue({ count: 2 });
const res = await del();
expect(res.status).toBe(200);
expect(tx.personalRecord.deleteMany).toHaveBeenCalledWith({
where: {
userId: "user-1",
metricSlot: { not: null },
source: "MANUAL",
externalId: MANUAL_ROW.externalId,
},
});
expect(enqueuePrDetection).toHaveBeenCalledWith("user-1", {
silent: true,
});
});
it("keys a record without an external id on the session's start", async () => {
vi.mocked(prisma.workout.findUnique).mockResolvedValue({
...MANUAL_ROW,
externalId: null,
} as never);
await del();
expect(tx.personalRecord.deleteMany).toHaveBeenCalledWith({
where: {
userId: "user-1",
metricSlot: { not: null },
source: "MANUAL",
externalId: null,
achievedAt: MANUAL_ROW.startedAt,
},
});
// Nothing was removed, so nothing needs re-deriving.
expect(enqueuePrDetection).not.toHaveBeenCalled();
});
it.each(["APPLE_HEALTH", "WITHINGS", "WHOOP", "EXTERNAL"])(
"refuses a %s workout with 409 and touches nothing",
async (source) => {
vi.mocked(prisma.workout.findUnique).mockResolvedValue({
...MANUAL_ROW,
source,
} as never);
const res = await del();
expect(res.status).toBe(409);
const body = await res.json();
expect(body.data).toBeNull();
expect(prisma.$transaction).not.toHaveBeenCalled();
expect(tx.workout.delete).not.toHaveBeenCalled();
expect(auditLog).not.toHaveBeenCalled();
},
);
it("answers 404 for another user's workout", async () => {
vi.mocked(prisma.workout.findUnique).mockResolvedValue({
...MANUAL_ROW,
userId: "someone-else",
} as never);
const res = await del();
expect(res.status).toBe(404);
expect(prisma.$transaction).not.toHaveBeenCalled();
});
it("answers 404 when there is no such workout", async () => {
vi.mocked(prisma.workout.findUnique).mockResolvedValue(null);
const res = await del("missing");
expect(res.status).toBe(404);
});
it("answers 401 without a session", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await del();
expect(res.status).toBe(401);
expect(prisma.workout.findUnique).not.toHaveBeenCalled();
});
});
+6
View File
@@ -10,6 +10,7 @@ import { SubPageShell } from "@/components/insights/sub-page-shell";
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 {
WorkoutDetailHeader,
WorkoutDetailStats,
@@ -64,6 +65,11 @@ 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.
headerAction={
data && !canonicalHref ? <DeleteWorkoutButton workout={data} /> : null
}
backLink={
<BackLink
href="/insights/workouts"
+41 -2
View File
@@ -1,7 +1,7 @@
"use client";
import { useMemo, useState } from "react";
import { Activity } from "lucide-react";
import { Activity, Plus } from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
@@ -10,6 +10,11 @@ import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { WorkoutList } from "@/components/insights/workout-list";
import { Skeleton } from "@/components/ui/skeleton";
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 { canLogWorkout } from "@/lib/workouts/manual-entry";
import { QueryErrorCard } from "@/components/ui/query-error-card";
/**
@@ -42,6 +47,10 @@ import { QueryErrorCard } from "@/components/ui/query-error-card";
export default function InsightsWorkoutsPageClient() {
const { t, tCount } = useTranslations();
const [sportType, setSportType] = useState<string | undefined>(undefined);
const [logOpen, setLogOpen] = useState(false);
const { user } = useAuth();
const capabilities = useRecordCapabilities();
const showLog = canLogWorkout(capabilities, user?.modules);
const {
workouts,
total,
@@ -80,7 +89,31 @@ export default function InsightsWorkoutsPageClient() {
description={t("insights.workouts.description")}
explainerMetric="workouts"
coachLaunch
headerAction={
showLog ? (
// Same 40 px ghost icon and widened hit area as the header
// cluster's other actions; it opens the form in place.
<Button
type="button"
variant="ghost"
size="icon"
data-slot="workout-log-manual"
aria-label={t("insights.workouts.manual.logWorkout")}
title={t("insights.workouts.manual.logWorkout")}
onClick={() => setLogOpen(true)}
className={cn(
"text-muted-foreground hover:text-foreground relative size-10",
"before:absolute before:-inset-1.5 before:content-['']",
)}
>
<Plus className="size-4" aria-hidden="true" />
</Button>
) : null
}
>
{showLog ? (
<ManualWorkoutSheet open={logOpen} onOpenChange={setLogOpen} />
) : null}
{/* No `<MetricRangeControls>` here: workouts are session records, not a
MeasurementType series, so the period-over-period range read has
nothing to aggregate. */}
@@ -100,7 +133,13 @@ export default function InsightsWorkoutsPageClient() {
icon={<Activity className="size-6" />}
title={t("insights.workouts.emptyState.title")}
description={t("insights.workouts.emptyState.description")}
cta={null}
cta={
showLog ? (
<Button size="sm" type="button" onClick={() => setLogOpen(true)}>
{t("insights.workouts.manual.logWorkout")}
</Button>
) : null
}
coachPrefill="I haven't logged any workouts yet — why does tracking them matter, and what should I focus on first?"
/>
) : (
@@ -126,7 +126,11 @@ function writeCaps(
sections: ShareDomain[] | null,
) {
const writable = new Set(delegatedDomains(level, sections, "write"));
return { canWriteDomain: (domain: ShareDomain) => writable.has(domain) };
return {
// A delegate is always inside somebody else's record.
inSharedRecord: true,
canWriteDomain: (domain: ShareDomain) => writable.has(domain),
};
}
const mockAccessRef: { value: AccountAccess } = { value: OWN_RECORD };
@@ -499,7 +503,7 @@ describe("the capture picker's kinds", () => {
/** The picker asks one question per kind, of that kind's own section. */
const caps = writeCaps;
const OWNER_CAPS = { canWriteDomain: () => true };
const OWNER_CAPS = { inSharedRecord: false, canWriteDomain: () => true };
const WRITER_CAPS = caps("write", null);
const READER_CAPS = caps("read", null);
// A guardian: every section with a delegated route answers at MANAGE.
@@ -558,6 +562,29 @@ describe("the capture picker's kinds", () => {
);
});
it("offers a workout in the caller's own record, gated by the workouts module", () => {
expect(visibleCaptureKinds(OWNER_CAPS, ["workout"])).toEqual(["workout"]);
expect(
visibleCaptureKinds(OWNER_CAPS, ["workout"], { workouts: false }),
).toEqual([]);
expect(
visibleCaptureKinds(OWNER_CAPS, ["workout"], { mood: false }),
).toEqual(["workout"]);
});
it("never offers a delegate a workout, at any level or scope", () => {
// The batch ingest resolves the caller (`requireAuth`), so no grant
// reaches it: a measurements-scoped MANAGE grant is refused the same way.
for (const delegate of [
WRITER_CAPS,
READER_CAPS,
GUARDIAN_CAPS,
caps("manage", ["measurements"]),
]) {
expect(visibleCaptureKinds(delegate, ["workout"])).toEqual([]);
}
});
it("offers a scoped MANAGE delegate the mood entry only with the mind section", () => {
expect(visibleCaptureKinds(caps("manage", ["mind"]), [...ALL])).toEqual([
"mood",
@@ -659,6 +686,20 @@ describe("a form opened before the record answered", () => {
);
});
it("withdraws the workout sheet in any shared record, and keeps it in one's own", () => {
for (const level of ["read", "write", "manage"] as const) {
expect(admittedQuickEntry("workout", writeCaps(level, null)), level).toBe(
null,
);
}
expect(
admittedQuickEntry("workout", {
inSharedRecord: false,
canWriteDomain: () => true,
}),
).toBe("workout");
});
it("keeps the mood sheet for a guardian, whose mind routes answer at MANAGE", () => {
expect(admittedQuickEntry("mood", writeCaps("manage", null))).toBe("mood");
});
@@ -678,7 +719,7 @@ describe("a form opened before the record answered", () => {
});
it("leaves the owner's own sheets alone", () => {
const OWNER_CAPS = { canWriteDomain: () => true };
const OWNER_CAPS = { inSharedRecord: false, canWriteDomain: () => true };
for (const sheet of ["measurement", "mood", "medicationIntake"] as const) {
expect(admittedQuickEntry(sheet, OWNER_CAPS), sheet).toBe(sheet);
}
@@ -11,7 +11,7 @@
* post-hydration name personalisation);
* 2. the SSR pass renders the name-less fallback (hydration-safe).
*/
import { describe, it, expect, vi } from "vitest";
import { beforeEach, describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
@@ -30,13 +30,24 @@ vi.mock("@/components/ui/dropdown-menu", () => ({
<>{children}</>
),
}));
const modulesRef: { value: Record<string, boolean> | undefined } = {
value: undefined,
};
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { username: "tester", timezone: "Europe/Berlin" },
user: {
username: "tester",
timezone: "Europe/Berlin",
modules: modulesRef.value,
},
}),
}));
import { DashboardHeader } from "../dashboard-header";
import {
CAPTURE_KIND_ORDER,
visibleCaptureKinds,
} from "@/components/layout/capture-picker";
function renderSSR(node: React.ReactElement, locale: "en" | "de" = "de") {
return renderToStaticMarkup(
@@ -85,3 +96,88 @@ describe("<DashboardHeader> — quick-add menu", () => {
expect(html).not.toContain("Log water");
});
});
describe("<DashboardHeader> — Log workout", () => {
beforeEach(() => {
modulesRef.value = undefined;
});
it("offers it beside the other capture entries", () => {
modulesRef.value = { workouts: true };
const html = renderSSR(
<DashboardHeader onQuickEntry={() => undefined} />,
"en",
);
expect(html).toContain("Log workout");
});
it("withholds it when the workouts module is off, and keeps the rest", () => {
modulesRef.value = { workouts: false };
const html = renderSSR(
<DashboardHeader onQuickEntry={() => undefined} />,
"en",
);
expect(html).not.toContain("Log workout");
expect(html).toContain("Log measurement");
});
});
describe("<DashboardHeader> — every entry follows its module, as the capture picker does", () => {
beforeEach(() => {
modulesRef.value = undefined;
});
it("withholds Log mood when the mood module is off", () => {
modulesRef.value = { mood: false };
const html = renderSSR(
<DashboardHeader onQuickEntry={() => undefined} />,
"en",
);
expect(html).not.toContain("Log mood");
expect(html).toContain("Log medication intake");
});
it("withholds Log medication intake when the medications module is off", () => {
modulesRef.value = { medications: false };
const html = renderSSR(
<DashboardHeader onQuickEntry={() => undefined} />,
"en",
);
expect(html).not.toContain("Log medication intake");
expect(html).toContain("Log mood");
});
it("offers exactly what the capture picker offers for the same modules", () => {
const cases: Array<Record<string, boolean> | undefined> = [
undefined,
{ mood: false },
{ medications: false },
{ workouts: false },
{ mood: false, medications: false, workouts: false },
];
for (const modules of cases) {
modulesRef.value = modules;
const html = renderSSR(
<DashboardHeader onQuickEntry={() => undefined} />,
"en",
);
const picker = visibleCaptureKinds(
{ inSharedRecord: false, canWriteDomain: () => true },
CAPTURE_KIND_ORDER,
modules,
);
const label = {
measurement: "Log measurement",
medication: "Log medication intake",
mood: "Log mood",
workout: "Log workout",
} as const;
for (const kind of CAPTURE_KIND_ORDER) {
expect(
html.includes(label[kind]),
`${kind} ${JSON.stringify(modules)}`,
).toBe(picker.includes(kind));
}
}
});
});
+30 -9
View File
@@ -12,7 +12,7 @@
*/
import { useMemo } from "react";
import Link from "next/link";
import { Activity, Pill, Plus, Waves, Wrench } from "lucide-react";
import { Activity, Footprints, Pill, Plus, Waves, Wrench } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@@ -26,6 +26,10 @@ import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { hourInTz } from "@/lib/tz/format";
import {
CAPTURE_KIND_ORDER,
visibleCaptureKinds,
} from "@/components/layout/capture-picker";
import type { QuickEntryDialog } from "@/components/dashboard/quick-entry-sheets";
export function DashboardHeader({
@@ -36,19 +40,30 @@ export function DashboardHeader({
const { t } = useTranslations();
const { user } = useAuth();
const mounted = useMounted();
// The quick-add offers three kinds, and each one is asked about its own
// The quick-add offers four kinds, and each one is asked about its own
// section: a reading under `measurements`, a dose under `medications`, a
// mood entry under `mind`. The coarse `canAdd` this used to gate the whole
// mood entry under `mind`, a workout in one's own record only. The coarse `canAdd` this used to gate the whole
// menu on carries the grant's LEVEL and no scope, so it answered true for a
// grant that opens neither of the two sections the menu never asked about.
// The customize shortcut points at a settings page sharing does not cover at
// all.
const { canWriteDomain, inSharedRecord } = useRecordCapabilities();
const canAddMeasurement = canWriteDomain("measurements");
const canAddIntake = canWriteDomain("medications");
// A mood entry is created at MANAGE under the mind section.
const canAddMood = canWriteDomain("mind");
const canAddAnything = canAddMeasurement || canAddIntake || canAddMood;
const capabilities = useRecordCapabilities();
const { inSharedRecord } = capabilities;
// The same answer the bottom bar's capture picker renders from: per kind,
// the record's section (a delegated grant) AND the kind's module
// (`capture:<kind>`). Asking the picker's own function is what keeps the two
// menus from disagreeing — this one used to check the section alone, so a
// switched-off mood module still offered "Log mood" here.
const offered = visibleCaptureKinds(
capabilities,
CAPTURE_KIND_ORDER,
user?.modules,
);
const canAddMeasurement = offered.includes("measurement");
const canAddIntake = offered.includes("medication");
const canAddMood = offered.includes("mood");
const canAddWorkout = offered.includes("workout");
const canAddAnything = offered.length > 0;
// The pre-hero greeting derivation, kept hydration-safe: `user` comes
// from the auth query, which can resolve before this boundary
@@ -183,6 +198,12 @@ export function DashboardHeader({
{t("dashboard.quickAddMedicationIntake")}
</DropdownMenuItem>
)}
{canAddWorkout && (
<DropdownMenuItem onClick={() => onQuickEntry("workout")}>
<Footprints className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddWorkout")}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
@@ -24,6 +24,7 @@ import {
import { MeasurementForm } from "@/components/measurements/measurement-form";
import { MoodForm } from "@/components/mood/mood-form";
import { MedicationIntakeQuickAdd } from "@/components/dashboard/medication-intake-quick-add";
import { ManualWorkoutForm } from "@/components/workouts/manual-workout-form";
import { useTranslations } from "@/lib/i18n/context";
import {
useRecordCapabilities,
@@ -32,15 +33,20 @@ import {
import type { ShareDomain } from "@/lib/sharing/scope";
export type QuickEntryDialog =
"measurement" | "mood" | "medicationIntake" | null;
"measurement" | "mood" | "medicationIntake" | "workout" | null;
/** The section each sheet writes to; see the capture picker. */
/**
* The section each sheet writes to; see the capture picker. `null` marks a
* sheet whose route resolves the caller rather than the record, which no
* grant reaches: a workout goes through the batch ingest, own record only.
*/
const QUICK_ENTRY_DOMAIN: Readonly<
Record<NonNullable<QuickEntryDialog>, ShareDomain>
Record<NonNullable<QuickEntryDialog>, ShareDomain | null>
> = {
measurement: "measurements",
medicationIntake: "medications",
mood: "mind",
workout: null,
};
/**
@@ -59,10 +65,13 @@ const QUICK_ENTRY_DOMAIN: Readonly<
*/
export function admittedQuickEntry(
open: QuickEntryDialog,
caps: Pick<RecordCapabilities, "canWriteDomain">,
caps: Pick<RecordCapabilities, "canWriteDomain" | "inSharedRecord">,
): QuickEntryDialog {
if (open === null) return open;
return caps.canWriteDomain(QUICK_ENTRY_DOMAIN[open]) ? open : null;
const domain = QUICK_ENTRY_DOMAIN[open];
const admitted =
domain === null ? !caps.inSharedRecord : caps.canWriteDomain(domain);
return admitted ? open : null;
}
/**
@@ -151,6 +160,9 @@ export function QuickEntrySheets({
// mobile soft keyboard.
const [medicationIntakeFooterEl, setMedicationIntakeFooterEl] =
useState<HTMLDivElement | null>(null);
const [workoutFooterEl, setWorkoutFooterEl] = useState<HTMLDivElement | null>(
null,
);
// v1.11.3 F3 — when an open quick-entry sheet is dismissed with
// unsaved input, hold the close in this flag and surface a confirm
// instead of nulling the dialog outright. Cleared once the user
@@ -214,6 +226,24 @@ export function QuickEntrySheets({
/>
</ResponsiveSheet>
{/* Log a workout by hand. Mounted only while open, so every opening
is a fresh form with its own external id. */}
<ResponsiveSheet
open={openSheet === "workout"}
onOpenChange={handleQuickEntryOpenChange}
title={t("insights.workouts.manual.sheetTitle")}
description={t("insights.workouts.manual.sheetDescription")}
footer={<div ref={setWorkoutFooterEl} className="flex w-full" />}
>
{openSheet === "workout" && (
<ManualWorkoutForm
onSuccess={onClose}
onCancel={onClose}
footerSlot={workoutFooterEl}
/>
)}
</ResponsiveSheet>
{/* v1.11.3 F3 — confirm before discarding a partly-filled
quick-entry form when the sheet is dismissed by an overlay
tap, Escape, or a mobile swipe-down. Keeps the sheet open
@@ -151,6 +151,13 @@ export interface SubPageShellProps {
* at the page foot.
*/
showAllValuesType?: string;
/**
* A page-owned action that opens something in place rather than linking
* away (the workouts page's "Log workout"). Leads the header cluster, in
* the same 40 px ghost-icon shape as its siblings; the page owns the
* control and whatever it opens.
*/
headerAction?: ReactNode;
children: ReactNode;
}
@@ -171,6 +178,7 @@ export function SubPageShell({
coachLaunch = false,
captureType,
showAllValuesType,
headerAction,
children,
}: SubPageShellProps) {
const { t } = useTranslations();
@@ -309,6 +317,7 @@ export function SubPageShell({
with a tighter gap the later sibling's invisible halo sat
on top of its neighbour's clickable edge. */}
<div className="flex shrink-0 items-center gap-3">
{headerAction}
{captureHref ? (
<Button
asChild
+31 -10
View File
@@ -1,11 +1,12 @@
"use client";
import { useState } from "react";
import { Activity, Pill, Waves } from "lucide-react";
import { Activity, Footprints, Pill, Waves } from "lucide-react";
import { MeasurementForm } from "@/components/measurements/measurement-form";
import { MoodForm } from "@/components/mood/mood-form";
import { MedicationIntakeQuickAdd } from "@/components/dashboard/medication-intake-quick-add";
import { ManualWorkoutForm } from "@/components/workouts/manual-workout-form";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import {
AlertDialog,
@@ -30,11 +31,12 @@ import {
/**
* The center "Log" capture action (iOS parity — the bottom bar's
* middle slot is a capture CTA, not a destination). It opens a small
* picker that routes to three existing quick-entry surfaces:
* picker that routes to four existing quick-entry surfaces:
*
* - Measurement → `<MeasurementForm>` (the `/measurements` add form)
* - Medication → `<MedicationIntakeQuickAdd>` (dashboard quick-add)
* - Mood → `<MoodForm>` (the `/mood` add form, 5-face flow)
* - Workout → `<ManualWorkoutForm>` (the workouts page's "Log workout")
*
* The picker reuses each existing capture component rather than
* rebuilding parallel forms.
@@ -50,7 +52,7 @@ import {
* instead of closing unconditionally.
*/
type CaptureKind = "measurement" | "medication" | "mood";
type CaptureKind = "measurement" | "medication" | "mood" | "workout";
/**
* The section each capture surface writes to.
@@ -66,10 +68,13 @@ type CaptureKind = "measurement" | "medication" | "mood";
* so asking it per section answers both cases and the mood entry's
* MANAGE-only create as well.
*/
const CAPTURE_KIND_DOMAIN: Readonly<Record<CaptureKind, ShareDomain>> = {
const CAPTURE_KIND_DOMAIN: Readonly<Record<CaptureKind, ShareDomain | null>> = {
measurement: "measurements",
medication: "medications",
mood: "mind",
// A workout goes through the batch ingest, which resolves the caller:
// no grant reaches it, so it is offered in one's own record only.
workout: null,
};
/** The order the chooser lists them in. */
@@ -77,6 +82,7 @@ export const CAPTURE_KIND_ORDER: ReadonlyArray<CaptureKind> = [
"measurement",
"medication",
"mood",
"workout",
];
/**
@@ -118,15 +124,16 @@ export function admittedCaptureKind(
* kind the record allows, the gate's default-on contract.
*/
export function visibleCaptureKinds(
caps: Pick<RecordCapabilities, "canWriteDomain">,
caps: Pick<RecordCapabilities, "canWriteDomain" | "inSharedRecord">,
kinds: ReadonlyArray<CaptureKind>,
modules?: SurfaceModuleMap | null,
): CaptureKind[] {
return kinds.filter(
(kind) =>
caps.canWriteDomain(CAPTURE_KIND_DOMAIN[kind]) &&
isSurfaceVisible(`capture:${kind}`, modules),
);
return kinds.filter((kind) => {
const domain = CAPTURE_KIND_DOMAIN[kind];
const writable =
domain === null ? !caps.inSharedRecord : caps.canWriteDomain(domain);
return writable && isSurfaceVisible(`capture:${kind}`, modules);
});
}
interface CapturePickerProps {
@@ -203,6 +210,12 @@ export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
description: t("nav.capture.moodDescription"),
icon: Waves,
},
{
kind: "workout",
label: t("nav.capture.workout"),
description: t("nav.capture.workoutDescription"),
icon: Footprints,
},
];
const options = allOptions.filter((opt) => offered.includes(opt.kind));
@@ -210,6 +223,7 @@ export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
measurement: t("measurements.addMeasurement"),
medication: t("nav.capture.medication"),
mood: t("mood.addEntry"),
workout: t("insights.workouts.manual.sheetTitle"),
};
// `openKind === null` keeps the form sheet closed (the title is unread
// then); the mood label is the harmless default, matching the prior
@@ -272,6 +286,13 @@ export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
footerSlot={footerEl}
/>
)}
{openKind === "workout" && (
<ManualWorkoutForm
onSuccess={closeForm}
onCancel={closeForm}
footerSlot={footerEl}
/>
)}
{openKind === "mood" && (
<MoodForm
onSuccess={closeForm}
@@ -0,0 +1,241 @@
/**
* `<ManualWorkoutForm>` and its save path, and the detail page's delete.
*
* SSR-only, like the other capture-form suites (vitest runs in `node`, no
* testing-library). The rules themselves live in `manual-entry.ts` and are
* pinned in its own suite; this one pins the wire and the markup:
*
* 1. the save posts ONE entry to `POST /api/workouts/batch`, MANUAL, with
* the form's external id, and refreshes the workout reads;
* 2. a `duplicate` (a second tap) is a success, a `skipped` entry is not;
* 3. the form renders its five fields, the distance in the reader's unit,
* and a layout that stacks distance / energy on a phone;
* 4. delete is offered for a hand-entered workout in one's own record only,
* and calls `DELETE /api/workouts/{id}`.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import type { QueryClient } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
}));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
const authRef: { unitPreference: "metric" | "imperial" } = {
unitPreference: "metric",
};
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: {
username: "tester",
timezone: "Europe/Berlin",
unitPreference: authRef.unitPreference,
},
}),
}));
import {
ManualWorkoutForm,
saveManualWorkout,
sportOptions,
} from "../manual-workout-form";
import {
canDeleteWorkout,
deleteManualWorkout,
} from "../delete-workout-button";
import type { ManualWorkoutEntry } from "@/lib/workouts/manual-entry";
const ENTRY: ManualWorkoutEntry = {
sportType: "cycling",
startedAt: "2026-09-15T06:00:00.000Z",
endedAt: "2026-09-15T07:30:00.000Z",
source: "MANUAL",
externalId: "manual:5b0f3c1e-1111-4222-8333-444455556666",
totalDistanceM: 32000,
};
function envelope(data: unknown, status = 200) {
return new Response(JSON.stringify({ data, error: null }), {
status,
headers: { "content-type": "application/json" },
});
}
function fakeClient() {
return {
invalidateQueries: vi.fn().mockResolvedValue(undefined),
removeQueries: vi.fn(),
};
}
function render(node: React.ReactElement) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
let fetchSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
authRef.unitPreference = "metric";
fetchSpy = vi.spyOn(globalThis, "fetch");
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("saveManualWorkout — the wire", () => {
it("posts one MANUAL entry with the form's external id to the batch route", async () => {
fetchSpy.mockResolvedValue(
envelope({ entries: [{ index: 0, status: "inserted" }] }),
);
const client = fakeClient();
const outcome = await saveManualWorkout(
ENTRY,
client as unknown as QueryClient,
);
expect(outcome).toBe("inserted");
expect(fetchSpy).toHaveBeenCalledTimes(1);
const [url, init] = fetchSpy.mock.calls[0]!;
expect(String(url)).toBe("/api/workouts/batch");
expect((init as RequestInit).method).toBe("POST");
const body = JSON.parse(String((init as RequestInit).body));
expect(body).toEqual({ workouts: [ENTRY] });
expect(body.workouts[0].source).toBe("MANUAL");
expect(body.workouts[0].externalId).toBe(ENTRY.externalId);
});
it("refreshes every workout read, and the daily reads", async () => {
fetchSpy.mockResolvedValue(
envelope({ entries: [{ index: 0, status: "inserted" }] }),
);
const client = fakeClient();
await saveManualWorkout(ENTRY, client as unknown as QueryClient);
const keys = client.invalidateQueries.mock.calls.map(([arg]) =>
JSON.stringify((arg as { queryKey: unknown }).queryKey),
);
expect(keys).toContain(JSON.stringify(queryKeys.workouts()));
expect(keys).toContain(JSON.stringify(queryKeys.dailyDigest()));
expect(keys).toContain(JSON.stringify(queryKeys.dashboardSnapshot()));
});
it("treats a duplicate (the same form submitted twice) as saved", async () => {
fetchSpy.mockResolvedValue(
envelope({ entries: [{ index: 0, status: "duplicate" }] }),
);
await expect(
saveManualWorkout(ENTRY, fakeClient() as unknown as QueryClient),
).resolves.toBe("duplicate");
});
it("throws when the route skipped the entry, and refreshes nothing", async () => {
fetchSpy.mockResolvedValue(
envelope({ entries: [{ index: 0, status: "skipped" }] }),
);
const client = fakeClient();
await expect(
saveManualWorkout(ENTRY, client as unknown as QueryClient),
).rejects.toThrow();
expect(client.invalidateQueries).not.toHaveBeenCalled();
});
});
describe("sportOptions", () => {
it("lists every sport by label, with Other last", () => {
const options = sportOptions((sport) => sport.toUpperCase());
expect(options).toHaveLength(21);
expect(options.at(-1)).toEqual({ value: "other", label: "OTHER" });
const labels = options.slice(0, -1).map((o) => o.label);
expect(labels).toEqual([...labels].sort((a, b) => a.localeCompare(b)));
});
});
describe("<ManualWorkoutForm> — markup", () => {
it("renders the five fields and nothing else", () => {
const html = render(<ManualWorkoutForm />);
expect(html).toContain('data-testid="manual-workout-sport"');
expect(html).toContain('data-testid="manual-workout-start"');
expect(html).toContain('data-testid="manual-workout-hours"');
expect(html).toContain('data-testid="manual-workout-minutes"');
expect(html).toContain('data-testid="manual-workout-distance"');
expect(html).toContain('data-testid="manual-workout-energy"');
expect(html).toContain("Activity");
expect(html).toContain("Duration");
expect(html).toContain("Active energy (kcal)");
// No heart rate, no sets, no route: a record, not a training log.
expect(html).not.toMatch(/heart|bpm|sets|reps/i);
});
it("names the distance in kilometres for a metric reader", () => {
expect(render(<ManualWorkoutForm />)).toContain("Distance (km)");
});
it("names the distance in miles for an imperial reader", () => {
authRef.unitPreference = "imperial";
expect(render(<ManualWorkoutForm />)).toContain("Distance (mi)");
});
it("keeps hours and minutes side by side and stacks distance and energy on a phone", () => {
const html = render(<ManualWorkoutForm />);
// Two short numeric fields fit a 390 px sheet side by side.
expect(html).toMatch(/class="grid grid-cols-2 gap-4"/);
// Distance and energy carry longer labels: one column below `sm`, two
// from 640 px up.
expect(html).toMatch(/class="grid gap-4 sm:grid-cols-2"/);
// The action buttons keep the 44 px touch floor on a phone.
expect(html).toContain("min-h-11 sm:min-h-9");
});
});
describe("delete — a hand-entered workout in one's own record only", () => {
it("is offered for a MANUAL workout in one's own record", () => {
expect(
canDeleteWorkout({ source: "MANUAL" }, { inSharedRecord: false }),
).toBe(true);
});
it.each([
"APPLE_HEALTH",
"WITHINGS",
"WHOOP",
"FITBIT",
"STRAVA",
"EXTERNAL",
])("is not offered for a %s workout", (source) => {
expect(canDeleteWorkout({ source }, { inSharedRecord: false })).toBe(false);
});
it("is not offered inside somebody else's record", () => {
expect(
canDeleteWorkout({ source: "MANUAL" }, { inSharedRecord: true }),
).toBe(false);
});
it("calls DELETE on the workout and drops its detail from the cache", async () => {
fetchSpy.mockResolvedValue(envelope({ deleted: true }));
const client = fakeClient();
await deleteManualWorkout("w-1", client as unknown as QueryClient);
const [url, init] = fetchSpy.mock.calls[0]!;
expect(String(url)).toBe("/api/workouts/w-1");
expect((init as RequestInit).method).toBe("DELETE");
expect(client.removeQueries).toHaveBeenCalledWith({
queryKey: queryKeys.workoutDetail("w-1"),
});
expect(client.invalidateQueries).toHaveBeenCalledWith({
queryKey: queryKeys.workouts(),
});
});
});
@@ -0,0 +1,138 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useQueryClient, type QueryClient } from "@tanstack/react-query";
import { Loader2, Trash2 } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { apiDelete } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys, refetchInactiveDailyReads } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
/**
* Whether a workout can be deleted from its detail page: one entered by hand,
* in one's own record. A synced workout would come back with the next sync,
* and `DELETE /api/workouts/{id}` refuses it (409); the route also resolves
* the caller, so a delegate is never offered it.
*/
export function canDeleteWorkout(
workout: { source: string },
caps: { inSharedRecord: boolean },
): boolean {
return workout.source === "MANUAL" && !caps.inSharedRecord;
}
/**
* Delete, then drop the deleted detail from the cache and refresh the rest of
* the workout reads (list, dashboard tile) and the two daily reads the route
* evicted server-side. The detail is removed rather than invalidated, so
* nothing refetches a row that no longer exists.
*/
export async function deleteManualWorkout(
id: string,
queryClient: QueryClient,
): Promise<void> {
await apiDelete(`/api/workouts/${encodeURIComponent(id)}`);
queryClient.removeQueries({ queryKey: queryKeys.workoutDetail(id) });
await queryClient.invalidateQueries({ queryKey: queryKeys.workouts() });
await refetchInactiveDailyReads(queryClient);
}
/**
* The detail page's delete control: a ghost icon in the page header cluster
* that asks before it deletes. Renders nothing for a workout it may not
* delete.
*/
export function DeleteWorkoutButton({
workout,
}: {
workout: { id: string; source: string };
}) {
const { t } = useTranslations();
const router = useRouter();
const queryClient = useQueryClient();
const capabilities = useRecordCapabilities();
const [open, setOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
if (!canDeleteWorkout(workout, capabilities)) return null;
async function handleDelete(e: React.MouseEvent) {
// Keep the dialog open while the request runs; it closes on success.
e.preventDefault();
if (deleting) return;
setDeleting(true);
try {
await deleteManualWorkout(workout.id, queryClient);
toastWrittenOutcome("success", t("insights.workouts.manual.deleted"));
setOpen(false);
router.replace("/insights/workouts");
} catch {
toastWrittenOutcome("failed", t("insights.workouts.manual.deleteError"));
} finally {
setDeleting(false);
}
}
return (
<>
<Button
type="button"
variant="ghost"
size="icon"
data-slot="workout-delete"
aria-label={t("insights.workouts.manual.deleteLabel")}
title={t("insights.workouts.manual.deleteLabel")}
onClick={() => setOpen(true)}
className={cn(
"text-muted-foreground hover:text-destructive relative size-10",
"before:absolute before:-inset-1.5 before:content-['']",
)}
>
<Trash2 className="size-4" aria-hidden="true" />
</Button>
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("insights.workouts.manual.deleteConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("insights.workouts.manual.deleteConfirmDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={handleDelete}
disabled={deleting}
data-testid="workout-delete-confirm"
>
{deleting ? (
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -0,0 +1,392 @@
"use client";
import { 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 { Button } from "@/components/ui/button";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { DateTimeField } from "@/components/ui/date-time-field";
import { FieldError } from "@/components/forms/field-error";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useAuth } from "@/hooks/use-auth";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { ApiError, apiPost } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { getQuantityTransform } from "@/lib/measurements/display-transform";
import { queryKeys, refetchInactiveDailyReads } from "@/lib/query-keys";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import {
buildManualWorkoutEntry,
emptyManualWorkoutDraft,
newManualWorkoutExternalId,
wallClockNow,
type ManualWorkoutDraft,
type ManualWorkoutEntry,
type ManualWorkoutField,
} from "@/lib/workouts/manual-entry";
import {
workoutSportTypeEnum,
type WorkoutSportType,
} from "@/lib/validations/workout";
/**
* Log a workout by hand: sport, start, duration, and optionally distance and
* active energy. Nothing else on purpose; see `manual-entry.ts`.
*
* Same contract as the other capture forms (`MoodForm`,
* `MedicationIntakeQuickAdd`): mounted inside a `ResponsiveSheet`, the action
* row portalled into the sheet's sticky footer through `footerSlot`, and
* `onSuccess` / `onCancel` close the sheet.
*/
interface ManualWorkoutFormProps {
onSuccess?: () => void;
onCancel?: () => void;
footerSlot?: HTMLElement | null;
}
interface BatchResult {
entries?: Array<{ status: string }>;
}
export type ManualWorkoutSaveOutcome = "inserted" | "duplicate";
/**
* Post the entry and refresh what shows workouts. A `duplicate` is a success:
* the same form already landed this session (a second tap, a retry), so the
* row exists exactly once. A `skipped` entry was refused and throws.
*/
export async function saveManualWorkout(
entry: ManualWorkoutEntry,
queryClient: QueryClient,
): Promise<ManualWorkoutSaveOutcome> {
const result = await apiPost<BatchResult>("/api/workouts/batch", {
workouts: [entry],
});
const status = result?.entries?.[0]?.status;
if (status !== "inserted" && status !== "duplicate") {
throw new Error(`workout entry not stored: ${status ?? "unknown"}`);
}
// The list, the dashboard tile and every detail read sit under this
// prefix. The form also opens from the capture picker on any page, where
// the Today hero (which names a workout that just landed) and the dashboard
// snapshot are unmounted, so those two are refetched outright.
await queryClient.invalidateQueries({ queryKey: queryKeys.workouts() });
await refetchInactiveDailyReads(queryClient);
return status;
}
/** The sports, by their label in the reader's language, "Other" last. */
export function sportOptions(
label: (sport: WorkoutSportType) => string,
): Array<{ value: WorkoutSportType; label: string }> {
const named = workoutSportTypeEnum.options
.filter((sport) => sport !== "other")
.map((value): { value: WorkoutSportType; label: string } => ({
value,
label: label(value),
}))
.sort((a, b) => a.label.localeCompare(b.label));
return [...named, { value: "other", label: label("other") }];
}
export function ManualWorkoutForm({
onSuccess,
onCancel,
footerSlot,
}: ManualWorkoutFormProps) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const { user } = useAuth();
const { preference } = useUnitDisplay();
const timezone = user?.timezone || DEFAULT_TIMEZONE;
// 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.
const [externalId] = useState(newManualWorkoutExternalId);
const [draft, setDraft] = useState<ManualWorkoutDraft>(() =>
emptyManualWorkoutDraft(new Date(), timezone),
);
const [errors, setErrors] = useState<
Partial<Record<ManualWorkoutField, string>>
>({});
const [saveError, setSaveError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const formId = useId();
const ids = {
sport: `${formId}-sport`,
start: `${formId}-start`,
hours: `${formId}-hours`,
minutes: `${formId}-minutes`,
distance: `${formId}-distance`,
energy: `${formId}-energy`,
durationError: `${formId}-duration-error`,
saveError: `${formId}-save-error`,
};
const distanceUnit = getQuantityTransform("distance", preference).displayUnit;
const options = useMemo(
() => sportOptions((sport) => t(`insights.workouts.sport.${sport}`)),
[t],
);
function update<K extends keyof ManualWorkoutDraft>(
key: K,
value: ManualWorkoutDraft[K],
) {
setDraft((prev) => ({ ...prev, [key]: value }));
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (saving) return;
setSaveError(null);
const built = buildManualWorkoutEntry(draft, {
timezone,
unitPreference: preference,
externalId,
now: new Date(),
});
if (!built.ok) {
setErrors(built.errors);
return;
}
setErrors({});
setSaving(true);
try {
const outcome = await saveManualWorkout(built.entry, queryClient);
// A duplicate wrote nothing new: the same form already landed this
// session. That is reported as such, not as a fresh save.
if (outcome === "duplicate") {
toastWrittenOutcome(
"empty",
t("insights.workouts.manual.alreadySaved"),
);
} else {
toastWrittenOutcome("success", t("common.saved"));
}
onSuccess?.();
} catch (err) {
setSaveError(
err instanceof ApiError && err.message
? err.message
: t("insights.workouts.manual.saveError"),
);
} finally {
setSaving(false);
}
}
const fieldError = (field: ManualWorkoutField) =>
errors[field] ? t(errors[field]) : undefined;
const describedBy = (field: ManualWorkoutField, id: string) =>
errors[field] ? id : undefined;
const footerNode = (
<div className="flex w-full items-center justify-end gap-2">
{onCancel && (
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={saving}
className="min-h-11 sm:min-h-9"
>
{t("common.cancel")}
</Button>
)}
<Button
type="submit"
form={formId}
disabled={saving}
className="min-h-11 sm:min-h-9"
data-testid="manual-workout-save"
>
{saving ? (
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
) : (
<Plus className="size-4" />
)}
{t("common.save")}
</Button>
</div>
);
return (
<form
id={formId}
onSubmit={handleSubmit}
noValidate
className="space-y-4"
data-testid="manual-workout-form"
>
<div className="space-y-2">
<Label htmlFor={ids.sport}>
{t("insights.workouts.manual.sportLabel")}
</Label>
<Select
value={draft.sportType}
onValueChange={(value) =>
update("sportType", value as WorkoutSportType)
}
>
<SelectTrigger
id={ids.sport}
className="w-full"
aria-invalid={!!errors.sportType || undefined}
aria-describedby={describedBy("sportType", `${ids.sport}-error`)}
data-testid="manual-workout-sport"
>
<SelectValue
placeholder={t("insights.workouts.manual.sportPlaceholder")}
/>
</SelectTrigger>
<SelectContent>
{options.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError
id={`${ids.sport}-error`}
message={fieldError("sportType")}
/>
</div>
<div className="space-y-2">
<Label htmlFor={ids.start}>
{t("insights.workouts.manual.startLabel")}
</Label>
<DateTimeField
id={ids.start}
value={draft.start}
onChange={(value) => update("start", value)}
max={wallClockNow(new Date(), timezone)}
required
aria-required="true"
aria-invalid={!!errors.start || undefined}
aria-describedby={describedBy("start", `${ids.start}-error`)}
data-testid="manual-workout-start"
/>
<FieldError id={`${ids.start}-error`} message={fieldError("start")} />
</div>
<fieldset
className="space-y-2"
aria-describedby={describedBy("duration", ids.durationError)}
>
<legend className="mb-2 text-sm leading-none font-medium">
{t("insights.workouts.manual.durationLabel")}
</legend>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label
htmlFor={ids.hours}
className="text-muted-foreground text-xs font-normal"
>
{t("common.hours")}
</Label>
<Input
id={ids.hours}
inputMode="numeric"
autoComplete="off"
value={draft.hours}
onChange={(e) => update("hours", e.target.value)}
aria-invalid={!!errors.duration || undefined}
data-testid="manual-workout-hours"
/>
</div>
<div className="space-y-1.5">
<Label
htmlFor={ids.minutes}
className="text-muted-foreground text-xs font-normal"
>
{t("common.minutes")}
</Label>
<Input
id={ids.minutes}
inputMode="numeric"
autoComplete="off"
value={draft.minutes}
onChange={(e) => update("minutes", e.target.value)}
aria-invalid={!!errors.duration || undefined}
data-testid="manual-workout-minutes"
/>
</div>
</div>
<FieldError id={ids.durationError} message={fieldError("duration")} />
</fieldset>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={ids.distance}>
{t("insights.workouts.manual.distanceLabel", {
unit: distanceUnit,
})}
</Label>
<Input
id={ids.distance}
inputMode="decimal"
autoComplete="off"
placeholder={t("common.optional")}
value={draft.distance}
onChange={(e) => update("distance", e.target.value)}
aria-invalid={!!errors.distance || undefined}
aria-describedby={describedBy("distance", `${ids.distance}-error`)}
data-testid="manual-workout-distance"
/>
<FieldError
id={`${ids.distance}-error`}
message={fieldError("distance")}
/>
</div>
<div className="space-y-2">
<Label htmlFor={ids.energy}>
{t("insights.workouts.manual.energyLabel")}
</Label>
<Input
id={ids.energy}
inputMode="decimal"
autoComplete="off"
placeholder={t("common.optional")}
value={draft.energyKcal}
onChange={(e) => update("energyKcal", e.target.value)}
aria-invalid={!!errors.energyKcal || undefined}
aria-describedby={describedBy("energyKcal", `${ids.energy}-error`)}
data-testid="manual-workout-energy"
/>
<FieldError
id={`${ids.energy}-error`}
message={fieldError("energyKcal")}
/>
</div>
</div>
{saveError && (
<div
id={ids.saveError}
role="alert"
aria-live="assertive"
className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm"
>
{saveError}
</div>
)}
{footerSlot ? createPortal(footerNode, footerSlot) : footerNode}
</form>
);
}
@@ -0,0 +1,94 @@
"use client";
import { useState } from "react";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { sheetBodyHasUnsavedInput } from "@/components/dashboard/quick-entry-sheets";
import { ManualWorkoutForm } from "@/components/workouts/manual-workout-form";
import { useTranslations } from "@/lib/i18n/context";
/**
* The "Log workout" sheet for a surface that owns its own open state (the
* workouts page header). The dashboard quick-add and the capture picker mount
* `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.
*/
export function ManualWorkoutSheet({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { t } = useTranslations();
const [footerEl, setFooterEl] = useState<HTMLDivElement | null>(null);
const [confirmDiscardOpen, setConfirmDiscardOpen] = useState(false);
function handleOpenChange(next: boolean) {
if (next) return onOpenChange(true);
if (sheetBodyHasUnsavedInput()) {
setConfirmDiscardOpen(true);
return;
}
onOpenChange(false);
}
return (
<>
<ResponsiveSheet
open={open}
onOpenChange={handleOpenChange}
title={t("insights.workouts.manual.sheetTitle")}
description={t("insights.workouts.manual.sheetDescription")}
footer={<div ref={setFooterEl} className="flex w-full" />}
>
{open && (
<ManualWorkoutForm
onSuccess={() => onOpenChange(false)}
onCancel={() => onOpenChange(false)}
footerSlot={footerEl}
/>
)}
</ResponsiveSheet>
<AlertDialog
open={confirmDiscardOpen}
onOpenChange={setConfirmDiscardOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("dashboard.quickEntryDiscard.title")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("dashboard.quickEntryDiscard.description")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("dashboard.quickEntryDiscard.cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setConfirmDiscardOpen(false);
onOpenChange(false);
}}
>
{t("dashboard.quickEntryDiscard.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -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 };
}