mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user