From 715268c62b24810c63f15d0d25ee5eb0c8839198 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 2 Oct 2026 21:23:01 +0200 Subject: [PATCH] feat(workouts): offer Log workout in the add menu and the capture picker The dashboard add menu and the bottom bar's capture picker gain a workout entry next to measurement, mood and medication. It belongs to the workouts module (capture:workout) and is offered in one's own record only, because the batch ingest resolves the caller and no grant reaches it. Refs #1085 --- .../delegated-write-affordances.test.tsx | 47 +++++++++++++++++-- .../__tests__/dashboard-header.test.tsx | 36 +++++++++++++- src/components/dashboard/dashboard-header.tsx | 17 +++++-- .../dashboard/quick-entry-sheets.tsx | 40 ++++++++++++++-- src/components/layout/capture-picker.tsx | 41 ++++++++++++---- .../__tests__/surface-module-registry.test.ts | 1 + src/lib/modules/surface.ts | 1 + 7 files changed, 160 insertions(+), 23 deletions(-) diff --git a/src/components/__tests__/delegated-write-affordances.test.tsx b/src/components/__tests__/delegated-write-affordances.test.tsx index 1dd578381..f8159ff8c 100644 --- a/src/components/__tests__/delegated-write-affordances.test.tsx +++ b/src/components/__tests__/delegated-write-affordances.test.tsx @@ -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); } diff --git a/src/components/dashboard/__tests__/dashboard-header.test.tsx b/src/components/dashboard/__tests__/dashboard-header.test.tsx index 4f7ae8422..5ac00b99d 100644 --- a/src/components/dashboard/__tests__/dashboard-header.test.tsx +++ b/src/components/dashboard/__tests__/dashboard-header.test.tsx @@ -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,9 +30,16 @@ vi.mock("@/components/ui/dropdown-menu", () => ({ <>{children} ), })); +const modulesRef: { value: Record | undefined } = { + value: undefined, +}; vi.mock("@/hooks/use-auth", () => ({ useAuth: () => ({ - user: { username: "tester", timezone: "Europe/Berlin" }, + user: { + username: "tester", + timezone: "Europe/Berlin", + modules: modulesRef.value, + }, }), })); @@ -85,3 +92,28 @@ describe(" — quick-add menu", () => { expect(html).not.toContain("Log water"); }); }); + +describe(" — Log workout", () => { + beforeEach(() => { + modulesRef.value = undefined; + }); + + it("offers it beside the other capture entries", () => { + modulesRef.value = { workouts: true }; + const html = renderSSR( + 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( + undefined} />, + "en", + ); + expect(html).not.toContain("Log workout"); + expect(html).toContain("Log measurement"); + }); +}); diff --git a/src/components/dashboard/dashboard-header.tsx b/src/components/dashboard/dashboard-header.tsx index 1e0b554cb..cb2842708 100644 --- a/src/components/dashboard/dashboard-header.tsx +++ b/src/components/dashboard/dashboard-header.tsx @@ -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,7 @@ 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 { canLogWorkout } from "@/lib/workouts/manual-entry"; import type { QuickEntryDialog } from "@/components/dashboard/quick-entry-sheets"; export function DashboardHeader({ @@ -43,12 +44,16 @@ export function DashboardHeader({ // 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 capabilities = useRecordCapabilities(); + const { canWriteDomain, inSharedRecord } = capabilities; 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; + // A workout: own record only, workouts module on (`canLogWorkout`). + const canAddWorkout = canLogWorkout(capabilities, user?.modules); + const canAddAnything = + canAddMeasurement || canAddIntake || canAddMood || canAddWorkout; // The pre-hero greeting derivation, kept hydration-safe: `user` comes // from the auth query, which can resolve before this boundary @@ -183,6 +188,12 @@ export function DashboardHeader({ {t("dashboard.quickAddMedicationIntake")} )} + {canAddWorkout && ( + onQuickEntry("workout")}> + + )} )} diff --git a/src/components/dashboard/quick-entry-sheets.tsx b/src/components/dashboard/quick-entry-sheets.tsx index 189958163..cd294a11a 100644 --- a/src/components/dashboard/quick-entry-sheets.tsx +++ b/src/components/dashboard/quick-entry-sheets.tsx @@ -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, ShareDomain> + Record, 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, + caps: Pick, ): 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(null); + const [workoutFooterEl, setWorkoutFooterEl] = useState( + 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({ /> + {/* Log a workout by hand. Mounted only while open, so every opening + is a fresh form with its own external id. */} + } + > + {openSheet === "workout" && ( + + )} + + {/* 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 diff --git a/src/components/layout/capture-picker.tsx b/src/components/layout/capture-picker.tsx index 068dd5f46..daddc6ef1 100644 --- a/src/components/layout/capture-picker.tsx +++ b/src/components/layout/capture-picker.tsx @@ -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 → `` (the `/measurements` add form) * - Medication → `` (dashboard quick-add) * - Mood → `` (the `/mood` add form, 5-face flow) + * - Workout → `` (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> = { +const CAPTURE_KIND_DOMAIN: Readonly> = { 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 = [ "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, + caps: Pick, kinds: ReadonlyArray, 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" && ( + + )} {openKind === "mood" && ( { ["overview:cycle-ring", "cycle"], ["capture:mood", "mood"], ["capture:medication", "medications"], + ["capture:workout", "workouts"], ["trend:mood", "mood"], ["widget:medications", "medications"], ["correlation:MEDICATION_COMPLIANCE", "medications"], diff --git a/src/lib/modules/surface.ts b/src/lib/modules/surface.ts index efc803457..4e39ab30f 100644 --- a/src/lib/modules/surface.ts +++ b/src/lib/modules/surface.ts @@ -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