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
This commit is contained in:
Marc-André Bombeck committed 2026-10-02 21:23:01 +02:00
1 parent e0fc879d72
commit 715268c62b
7 files changed
+160 -23

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,9 +30,16 @@ 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,
},
}),
}));
@@ -85,3 +92,28 @@ 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");
});
});
+14 -3
View File
@@ -12,7 +12,7 @@
*/
import { useMemo } from "react";
import Link from "next/link";
import { Activity, Pill, Plus, Waves, Wrench } from "lucide-react";
import { Activity, Footprints, Pill, Plus, Waves, Wrench } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@@ -26,6 +26,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")}
</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
+31 -10
View File
@@ -1,11 +1,12 @@
"use client";
import { useState } from "react";
import { Activity, Pill, Waves } from "lucide-react";
import { Activity, Footprints, Pill, Waves } from "lucide-react";
import { MeasurementForm } from "@/components/measurements/measurement-form";
import { MoodForm } from "@/components/mood/mood-form";
import { MedicationIntakeQuickAdd } from "@/components/dashboard/medication-intake-quick-add";
import { ManualWorkoutForm } from "@/components/workouts/manual-workout-form";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import {
AlertDialog,
@@ -30,11 +31,12 @@ import {
/**
* The center "Log" capture action (iOS parity — the bottom bar's
* middle slot is a capture CTA, not a destination). It opens a small
* picker that routes to three existing quick-entry surfaces:
* picker that routes to four existing quick-entry surfaces:
*
* - Measurement → `<MeasurementForm>` (the `/measurements` add form)
* - Medication → `<MedicationIntakeQuickAdd>` (dashboard quick-add)
* - Mood → `<MoodForm>` (the `/mood` add form, 5-face flow)
* - Workout → `<ManualWorkoutForm>` (the workouts page's "Log workout")
*
* The picker reuses each existing capture component rather than
* rebuilding parallel forms.
@@ -50,7 +52,7 @@ import {
* instead of closing unconditionally.
*/
type CaptureKind = "measurement" | "medication" | "mood";
type CaptureKind = "measurement" | "medication" | "mood" | "workout";
/**
* The section each capture surface writes to.
@@ -66,10 +68,13 @@ type CaptureKind = "measurement" | "medication" | "mood";
* so asking it per section answers both cases and the mood entry's
* MANAGE-only create as well.
*/
const CAPTURE_KIND_DOMAIN: Readonly<Record<CaptureKind, ShareDomain>> = {
const CAPTURE_KIND_DOMAIN: Readonly<Record<CaptureKind, ShareDomain | null>> = {
measurement: "measurements",
medication: "medications",
mood: "mind",
// A workout goes through the batch ingest, which resolves the caller:
// no grant reaches it, so it is offered in one's own record only.
workout: null,
};
/** The order the chooser lists them in. */
@@ -77,6 +82,7 @@ export const CAPTURE_KIND_ORDER: ReadonlyArray<CaptureKind> = [
"measurement",
"medication",
"mood",
"workout",
];
/**
@@ -118,15 +124,16 @@ export function admittedCaptureKind(
* kind the record allows, the gate's default-on contract.
*/
export function visibleCaptureKinds(
caps: Pick<RecordCapabilities, "canWriteDomain">,
caps: Pick<RecordCapabilities, "canWriteDomain" | "inSharedRecord">,
kinds: ReadonlyArray<CaptureKind>,
modules?: SurfaceModuleMap | null,
): CaptureKind[] {
return kinds.filter(
(kind) =>
caps.canWriteDomain(CAPTURE_KIND_DOMAIN[kind]) &&
isSurfaceVisible(`capture:${kind}`, modules),
);
return kinds.filter((kind) => {
const domain = CAPTURE_KIND_DOMAIN[kind];
const writable =
domain === null ? !caps.inSharedRecord : caps.canWriteDomain(domain);
return writable && isSurfaceVisible(`capture:${kind}`, modules);
});
}
interface CapturePickerProps {
@@ -203,6 +210,12 @@ export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
description: t("nav.capture.moodDescription"),
icon: Waves,
},
{
kind: "workout",
label: t("nav.capture.workout"),
description: t("nav.capture.workoutDescription"),
icon: Footprints,
},
];
const options = allOptions.filter((opt) => offered.includes(opt.kind));
@@ -210,6 +223,7 @@ export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
measurement: t("measurements.addMeasurement"),
medication: t("nav.capture.medication"),
mood: t("mood.addEntry"),
workout: t("insights.workouts.manual.sheetTitle"),
};
// `openKind === null` keeps the form sheet closed (the title is unread
// then); the mood label is the harmless default, matching the prior
@@ -272,6 +286,13 @@ export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
footerSlot={footerEl}
/>
)}
{openKind === "workout" && (
<ManualWorkoutForm
onSuccess={closeForm}
onCancel={closeForm}
footerSlot={footerEl}
/>
)}
{openKind === "mood" && (
<MoodForm
onSuccess={closeForm}
@@ -255,6 +255,7 @@ describe("every surface that shows a module's data is owned by it", () => {
["overview:cycle-ring", "cycle"],
["capture:mood", "mood"],
["capture:medication", "medications"],
["capture:workout", "workouts"],
["trend:mood", "mood"],
["widget:medications", "medications"],
["correlation:MEDICATION_COMPLIANCE", "medications"],
+1
View File
@@ -123,6 +123,7 @@ const STATIC_SURFACE_MODULE = {
// ── Add menu ──
"capture:mood": "mood",
"capture:medication": "medications",
"capture:workout": "workouts",
// ── Dashboard widgets ──
// CORE widgets (weight, blood pressure, pulse, body fat, the vital-derived