mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge pull request #1089 from MBombeck/feat/manual-workout-entry
Log a workout by hand
This commit is contained in:
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 ─────────────────────────────────────────
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user