mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
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:
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");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user