Merge pull request #1089 from MBombeck/feat/manual-workout-entry

Log a workout by hand
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-10-02 23:59:11 +02:00
commit 2473d52f19
31 files changed
+2579 -61

No files matched your search

@@ -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));
}
}
});
});
+30 -9
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,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