mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +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
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user