Merge remote-tracking branch 'origin/main' into fix/ai-timeout-everywhere

This commit is contained in:
Marc-André Bombeck committed 2026-10-02 23:59:38 +02:00
commit 0f6669847c
41 files changed
+3055 -129

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,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));
}
}
});
});
@@ -0,0 +1,207 @@
/**
* Every dashboard tile whose metric has an Insights sub-page opens it.
*
* The body fat tile was rendered without a link while its siblings each
* opened their page, and nothing noticed, because the href map was a
* hand-kept list beside the render code with no check tying the two
* together. This guard derives the tile set from the registry
* (`TILE_CAPABLE_WIDGET_IDS`) and from the dashboard's own render code, and
* holds every tile against the sub-page registry.
*/
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
TILE_CAPABLE_WIDGET_IDS,
type TileCapableWidgetId,
} from "@/components/dashboard/dashboard-gates";
import {
TILE_DESTINATION,
dashboardTileHref,
tileWidgetId,
} from "@/components/dashboard/tile-destinations";
import { subPageSlugForType } from "@/lib/insights/sub-page-metric";
import { surfaceModule } from "@/lib/modules/surface";
const ROOT = path.resolve(__dirname, "../../../..");
/**
* The series each tile paints. Kept here rather than in production code
* because only this guard needs it; a tile id missing from this record
* fails the totality test below, so a new tile cannot skip the question.
*/
const TILE_TYPES: Record<TileCapableWidgetId, readonly string[]> = {
weight: ["WEIGHT"],
bp: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"],
pulse: ["PULSE"],
bodyFat: ["BODY_FAT"],
mood: ["MOOD"],
sleep: ["SLEEP_DURATION"],
steps: ["ACTIVITY_STEPS"],
glucose: ["BLOOD_GLUCOSE"],
bpInTarget: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"],
vo2Max: ["VO2_MAX"],
hrv: ["HEART_RATE_VARIABILITY", "HRV_RMSSD"],
oxygenSaturation: ["OXYGEN_SATURATION"],
respiratoryRate: ["RESPIRATORY_RATE"],
wristTemperature: ["WRIST_TEMPERATURE"],
muscleMass: ["MUSCLE_MASS"],
totalBodyWater: ["TOTAL_BODY_WATER"],
boneMass: ["BONE_MASS"],
waterIntake: ["NUTRIENT_WATER"],
};
/**
* Tiles whose series is not a `MeasurementType` with a sub-page, but which
* still lead to a page that shows it. Each entry names its reason.
*/
const NON_MEASUREMENT_DESTINATION: Partial<
Record<TileCapableWidgetId, { slug: string; reason: string }>
> = {
waterIntake: {
slug: "nutrients",
reason:
"fluid intake is a NutrientIntakeDay total, charted on the nutrients page",
},
};
/**
* Tiles that deliberately lead nowhere, with the reason. Empty today: every
* tile has a page. An entry here must be a tile whose destination is null.
*/
const NO_DESTINATION: Partial<Record<TileCapableWidgetId, string>> = {};
/** Tile ids the dashboard pushes into its strip, read from the render code. */
function renderedTileIds(): string[] {
const source = readFileSync(
path.join(ROOT, "src/app/page-client.tsx"),
"utf8",
);
const ids: string[] = [];
const pattern =
/trendCards\.push\(\{\s*id:\s*(?:"([^"]+)"|`([^`$]+)\$\{[^`]*`)/g;
for (const match of source.matchAll(pattern)) {
ids.push(match[1] ?? `${match[2]}*`);
}
return ids;
}
describe("dashboard tile destinations", () => {
it("reads a non-trivial tile set from the registry and the render code", () => {
expect(TILE_CAPABLE_WIDGET_IDS.length).toBeGreaterThanOrEqual(18);
expect(renderedTileIds().length).toBeGreaterThanOrEqual(19);
});
it("knows every tile the dashboard renders, and renders every known tile", () => {
const rendered = renderedTileIds();
const widgets = new Set<string>();
for (const id of rendered) {
const sample = id.endsWith("*") ? `${id.slice(0, -1)}X` : id;
const widget = tileWidgetId(sample);
expect(widget, `rendered tile "${id}" has no widget`).not.toBeNull();
widgets.add(widget as string);
}
expect([...widgets].sort()).toEqual([...TILE_CAPABLE_WIDGET_IDS].sort());
});
it("routes the dashboard's tile links through this map and no other", () => {
const source = readFileSync(
path.join(ROOT, "src/app/page-client.tsx"),
"utf8",
);
expect(source).toMatch(/dashboardTileHref\(entry\.id,/);
expect(source).not.toMatch(/"\/insights\/[a-z-]+"/);
});
it("declares the series of every tile", () => {
expect(Object.keys(TILE_TYPES).sort()).toEqual(
[...TILE_CAPABLE_WIDGET_IDS].sort(),
);
});
it.each([...TILE_CAPABLE_WIDGET_IDS])(
"%s opens the page of the metric it shows",
(widget) => {
const destination = TILE_DESTINATION[widget];
const pages = new Set(
TILE_TYPES[widget]
.map((type) => subPageSlugForType(type))
.filter((slug): slug is NonNullable<typeof slug> => slug != null),
);
if (pages.size > 0) {
expect(
destination,
`${widget} shows ${TILE_TYPES[widget].join(", ")}, which has a page, but links nowhere`,
).not.toBeNull();
expect(pages.has(destination!)).toBe(true);
return;
}
const nonMeasurement = NON_MEASUREMENT_DESTINATION[widget];
if (nonMeasurement) {
expect(destination).toBe(nonMeasurement.slug);
expect(nonMeasurement.reason.length).toBeGreaterThan(10);
return;
}
expect(destination).toBeNull();
expect(
NO_DESTINATION[widget],
`${widget} has no destination and no written reason`,
).toBeTruthy();
},
);
it("allowlists only tiles that really lead nowhere", () => {
for (const widget of Object.keys(NO_DESTINATION) as TileCapableWidgetId[]) {
expect(TILE_DESTINATION[widget]).toBeNull();
}
});
it("links only to sub-pages that exist as routes", () => {
const slugs = Object.values(TILE_DESTINATION).filter(
(slug): slug is NonNullable<typeof slug> => slug !== null,
);
expect(slugs.length).toBeGreaterThanOrEqual(18);
for (const slug of slugs) {
expect(
existsSync(path.join(ROOT, "src/app/insights", slug, "page.tsx")),
`/insights/${slug} has no page`,
).toBe(true);
}
});
it("never links a tile to a page owned by a different module", () => {
for (const widget of TILE_CAPABLE_WIDGET_IDS) {
const slug = TILE_DESTINATION[widget];
if (slug === null) continue;
const pageOwner = surfaceModule(`insights-page:${slug}`);
if (pageOwner === undefined) continue;
expect(
surfaceModule(`widget:${widget}`),
`${widget} links to /insights/${slug}, owned by ${pageOwner}`,
).toBe(pageOwner);
}
});
it("resolves the rendered tile ids to their hrefs", () => {
expect(dashboardTileHref("bodyFat", undefined)).toBe("/insights/body-fat");
expect(dashboardTileHref("bp-sys", undefined)).toBe(
"/insights/blood-pressure",
);
expect(dashboardTileHref("bp-dia", null)).toBe("/insights/blood-pressure");
expect(dashboardTileHref("glucose-FASTING", {})).toBe(
"/insights/blood-glucose",
);
expect(dashboardTileHref("not-a-tile", undefined)).toBeNull();
});
it("drops the link when the destination's module is switched off", () => {
expect(dashboardTileHref("mood", { mood: false })).toBeNull();
expect(dashboardTileHref("glucose-FASTING", { glucose: false })).toBeNull();
expect(dashboardTileHref("waterIntake", { nutrients: false })).toBeNull();
expect(dashboardTileHref("bodyFat", { mood: false })).toBe(
"/insights/body-fat",
);
});
});
+13 -4
View File
@@ -11,7 +11,10 @@ import {
GLUCOSE_CONTEXT_BUCKETS,
type GlucoseContextBucket,
} from "@/lib/glucose";
import type { DashboardLayout } from "@/lib/dashboard-layout";
import type {
DashboardLayout,
DashboardWidgetId,
} from "@/lib/dashboard-layout";
/**
* v1.7.0 — first-paint gate for the dashboard tile strip.
@@ -49,7 +52,7 @@ export function resolveDashboardFirstPaintGate(input: {
* path at all — counting any of them over-reserved the loading
* silhouette and made the strip reshuffle when the data landed.
*/
const TILE_CAPABLE_WIDGET_IDS = new Set<string>([
export const TILE_CAPABLE_WIDGET_IDS = [
"weight",
"bp",
"pulse",
@@ -72,7 +75,13 @@ const TILE_CAPABLE_WIDGET_IDS = new Set<string>([
// v1.29 — fluid intake strip tile: paints one strip card and self-gates
// on having a NUTRIENT_WATER sample.
"waterIntake",
]);
] as const satisfies readonly DashboardWidgetId[];
export type TileCapableWidgetId = (typeof TILE_CAPABLE_WIDGET_IDS)[number];
const TILE_CAPABLE_WIDGET_ID_SET: ReadonlySet<string> = new Set(
TILE_CAPABLE_WIDGET_IDS,
);
/**
* v1.16.8 — silhouette count for the tile-strip skeleton: one card per
@@ -85,7 +94,7 @@ const TILE_CAPABLE_WIDGET_IDS = new Set<string>([
export function resolveConfiguredTileCount(layout: DashboardLayout): number {
let count = 0;
for (const widget of layout.widgets) {
if (!TILE_CAPABLE_WIDGET_IDS.has(widget.id)) continue;
if (!TILE_CAPABLE_WIDGET_ID_SET.has(widget.id)) continue;
if (!(widget.tileVisible ?? widget.visible)) continue;
count += widget.id === "bp" ? 2 : 1;
}
+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
@@ -0,0 +1,81 @@
/**
* Where each dashboard strip tile leads.
*
* A tile shows one metric, and every metric with an Insights sub-page should
* open it on click. The map is keyed by the tile-capable widget id and typed
* against the sub-page slug union, so a destination that does not exist is a
* compile error and a tile added to `TILE_CAPABLE_WIDGET_IDS` without a
* decision here is one too. `null` means the tile deliberately leads nowhere;
* the guard in `__tests__/tile-destinations.test.ts` demands a written reason
* for every such entry and fails when a tile's metric has a sub-page the tile
* does not open. It exists because the body fat tile was the one tile that
* led nowhere, documented as having "no dedicated sub-page" while every
* body-composition sibling had one.
*
* A link never points at a page the account cannot see: the destination
* passes the same `insights-page:<slug>` surface gate the tab strip uses.
*/
import {
INSIGHTS_OVERVIEW_PATH,
type SubPageSlug,
} from "@/lib/insights/sub-page-metric";
import { isSurfaceVisible, type SurfaceModuleMap } from "@/lib/modules/surface";
import {
TILE_CAPABLE_WIDGET_IDS,
type TileCapableWidgetId,
} from "./dashboard-gates";
export const TILE_DESTINATION: Readonly<
Record<TileCapableWidgetId, SubPageSlug | null>
> = {
weight: "weight",
bp: "blood-pressure",
pulse: "pulse",
bodyFat: "body-fat",
mood: "mood",
sleep: "sleep",
steps: "steps",
glucose: "blood-glucose",
bpInTarget: "blood-pressure",
vo2Max: "cardio-fitness",
hrv: "hrv",
oxygenSaturation: "oxygen",
respiratoryRate: "respiratory-rate",
wristTemperature: "wrist-temperature",
muscleMass: "muscle-mass",
totalBodyWater: "body-water",
boneMass: "bone-mass",
// Fluid intake lives on the nutrients page (hydration hero + quick-add),
// not on a Measurement-backed detail page.
waterIntake: "nutrients",
};
const TILE_WIDGET_IDS: ReadonlySet<string> = new Set(TILE_CAPABLE_WIDGET_IDS);
/**
* The widget a rendered tile belongs to. Blood pressure paints two tiles
* (`bp-sys`, `bp-dia`) and glucose one per logged context (`glucose-<ctx>`);
* every other tile carries its widget id verbatim.
*/
export function tileWidgetId(tileId: string): TileCapableWidgetId | null {
if (tileId === "bp-sys" || tileId === "bp-dia") return "bp";
if (tileId.startsWith("glucose-")) return "glucose";
return TILE_WIDGET_IDS.has(tileId) ? (tileId as TileCapableWidgetId) : null;
}
/**
* The href a rendered tile links to, or null when it has no destination or
* the destination page is hidden by a switched-off module.
*/
export function dashboardTileHref(
tileId: string,
modules: SurfaceModuleMap | null | undefined,
): string | null {
const widget = tileWidgetId(tileId);
if (widget === null) return null;
const slug = TILE_DESTINATION[widget];
if (slug === null) return null;
if (!isSurfaceVisible(`insights-page:${slug}`, modules)) return null;
return `${INSIGHTS_OVERVIEW_PATH}/${slug}`;
}
@@ -233,6 +233,7 @@ export const SUB_PAGE_TABS: Record<
// ── body composition ──
weight: { labelKey: "insights.navWeight", metric: "WEIGHT" },
bmi: { labelKey: "insights.navBmi", metric: "BMI" },
"body-fat": { labelKey: "insights.navBodyFat", metric: "BODY_FAT" },
"body-water": {
labelKey: "insights.navTotalBodyWater",
metric: "TOTAL_BODY_WATER",
@@ -151,6 +151,13 @@ export interface SubPageShellProps {
* at the page foot.
*/
showAllValuesType?: string;
/**
* A page-owned action that opens something in place rather than linking
* away (the workouts page's "Log workout"). Leads the header cluster, in
* the same 40 px ghost-icon shape as its siblings; the page owns the
* control and whatever it opens.
*/
headerAction?: ReactNode;
children: ReactNode;
}
@@ -171,6 +178,7 @@ export function SubPageShell({
coachLaunch = false,
captureType,
showAllValuesType,
headerAction,
children,
}: SubPageShellProps) {
const { t } = useTranslations();
@@ -309,6 +317,7 @@ export function SubPageShell({
with a tighter gap the later sibling's invisible halo sat
on top of its neighbour's clickable edge. */}
<div className="flex shrink-0 items-center gap-3">
{headerAction}
{captureHref ? (
<Button
asChild
+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}
@@ -0,0 +1,241 @@
/**
* `<ManualWorkoutForm>` and its save path, and the detail page's delete.
*
* SSR-only, like the other capture-form suites (vitest runs in `node`, no
* testing-library). The rules themselves live in `manual-entry.ts` and are
* pinned in its own suite; this one pins the wire and the markup:
*
* 1. the save posts ONE entry to `POST /api/workouts/batch`, MANUAL, with
* the form's external id, and refreshes the workout reads;
* 2. a `duplicate` (a second tap) is a success, a `skipped` entry is not;
* 3. the form renders its five fields, the distance in the reader's unit,
* and a layout that stacks distance / energy on a phone;
* 4. delete is offered for a hand-entered workout in one's own record only,
* and calls `DELETE /api/workouts/{id}`.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import type { QueryClient } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
}));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
const authRef: { unitPreference: "metric" | "imperial" } = {
unitPreference: "metric",
};
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: {
username: "tester",
timezone: "Europe/Berlin",
unitPreference: authRef.unitPreference,
},
}),
}));
import {
ManualWorkoutForm,
saveManualWorkout,
sportOptions,
} from "../manual-workout-form";
import {
canDeleteWorkout,
deleteManualWorkout,
} from "../delete-workout-button";
import type { ManualWorkoutEntry } from "@/lib/workouts/manual-entry";
const ENTRY: ManualWorkoutEntry = {
sportType: "cycling",
startedAt: "2026-09-15T06:00:00.000Z",
endedAt: "2026-09-15T07:30:00.000Z",
source: "MANUAL",
externalId: "manual:5b0f3c1e-1111-4222-8333-444455556666",
totalDistanceM: 32000,
};
function envelope(data: unknown, status = 200) {
return new Response(JSON.stringify({ data, error: null }), {
status,
headers: { "content-type": "application/json" },
});
}
function fakeClient() {
return {
invalidateQueries: vi.fn().mockResolvedValue(undefined),
removeQueries: vi.fn(),
};
}
function render(node: React.ReactElement) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
let fetchSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
authRef.unitPreference = "metric";
fetchSpy = vi.spyOn(globalThis, "fetch");
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("saveManualWorkout — the wire", () => {
it("posts one MANUAL entry with the form's external id to the batch route", async () => {
fetchSpy.mockResolvedValue(
envelope({ entries: [{ index: 0, status: "inserted" }] }),
);
const client = fakeClient();
const outcome = await saveManualWorkout(
ENTRY,
client as unknown as QueryClient,
);
expect(outcome).toBe("inserted");
expect(fetchSpy).toHaveBeenCalledTimes(1);
const [url, init] = fetchSpy.mock.calls[0]!;
expect(String(url)).toBe("/api/workouts/batch");
expect((init as RequestInit).method).toBe("POST");
const body = JSON.parse(String((init as RequestInit).body));
expect(body).toEqual({ workouts: [ENTRY] });
expect(body.workouts[0].source).toBe("MANUAL");
expect(body.workouts[0].externalId).toBe(ENTRY.externalId);
});
it("refreshes every workout read, and the daily reads", async () => {
fetchSpy.mockResolvedValue(
envelope({ entries: [{ index: 0, status: "inserted" }] }),
);
const client = fakeClient();
await saveManualWorkout(ENTRY, client as unknown as QueryClient);
const keys = client.invalidateQueries.mock.calls.map(([arg]) =>
JSON.stringify((arg as { queryKey: unknown }).queryKey),
);
expect(keys).toContain(JSON.stringify(queryKeys.workouts()));
expect(keys).toContain(JSON.stringify(queryKeys.dailyDigest()));
expect(keys).toContain(JSON.stringify(queryKeys.dashboardSnapshot()));
});
it("treats a duplicate (the same form submitted twice) as saved", async () => {
fetchSpy.mockResolvedValue(
envelope({ entries: [{ index: 0, status: "duplicate" }] }),
);
await expect(
saveManualWorkout(ENTRY, fakeClient() as unknown as QueryClient),
).resolves.toBe("duplicate");
});
it("throws when the route skipped the entry, and refreshes nothing", async () => {
fetchSpy.mockResolvedValue(
envelope({ entries: [{ index: 0, status: "skipped" }] }),
);
const client = fakeClient();
await expect(
saveManualWorkout(ENTRY, client as unknown as QueryClient),
).rejects.toThrow();
expect(client.invalidateQueries).not.toHaveBeenCalled();
});
});
describe("sportOptions", () => {
it("lists every sport by label, with Other last", () => {
const options = sportOptions((sport) => sport.toUpperCase());
expect(options).toHaveLength(21);
expect(options.at(-1)).toEqual({ value: "other", label: "OTHER" });
const labels = options.slice(0, -1).map((o) => o.label);
expect(labels).toEqual([...labels].sort((a, b) => a.localeCompare(b)));
});
});
describe("<ManualWorkoutForm> — markup", () => {
it("renders the five fields and nothing else", () => {
const html = render(<ManualWorkoutForm />);
expect(html).toContain('data-testid="manual-workout-sport"');
expect(html).toContain('data-testid="manual-workout-start"');
expect(html).toContain('data-testid="manual-workout-hours"');
expect(html).toContain('data-testid="manual-workout-minutes"');
expect(html).toContain('data-testid="manual-workout-distance"');
expect(html).toContain('data-testid="manual-workout-energy"');
expect(html).toContain("Activity");
expect(html).toContain("Duration");
expect(html).toContain("Active energy (kcal)");
// No heart rate, no sets, no route: a record, not a training log.
expect(html).not.toMatch(/heart|bpm|sets|reps/i);
});
it("names the distance in kilometres for a metric reader", () => {
expect(render(<ManualWorkoutForm />)).toContain("Distance (km)");
});
it("names the distance in miles for an imperial reader", () => {
authRef.unitPreference = "imperial";
expect(render(<ManualWorkoutForm />)).toContain("Distance (mi)");
});
it("keeps hours and minutes side by side and stacks distance and energy on a phone", () => {
const html = render(<ManualWorkoutForm />);
// Two short numeric fields fit a 390 px sheet side by side.
expect(html).toMatch(/class="grid grid-cols-2 gap-4"/);
// Distance and energy carry longer labels: one column below `sm`, two
// from 640 px up.
expect(html).toMatch(/class="grid gap-4 sm:grid-cols-2"/);
// The action buttons keep the 44 px touch floor on a phone.
expect(html).toContain("min-h-11 sm:min-h-9");
});
});
describe("delete — a hand-entered workout in one's own record only", () => {
it("is offered for a MANUAL workout in one's own record", () => {
expect(
canDeleteWorkout({ source: "MANUAL" }, { inSharedRecord: false }),
).toBe(true);
});
it.each([
"APPLE_HEALTH",
"WITHINGS",
"WHOOP",
"FITBIT",
"STRAVA",
"EXTERNAL",
])("is not offered for a %s workout", (source) => {
expect(canDeleteWorkout({ source }, { inSharedRecord: false })).toBe(false);
});
it("is not offered inside somebody else's record", () => {
expect(
canDeleteWorkout({ source: "MANUAL" }, { inSharedRecord: true }),
).toBe(false);
});
it("calls DELETE on the workout and drops its detail from the cache", async () => {
fetchSpy.mockResolvedValue(envelope({ deleted: true }));
const client = fakeClient();
await deleteManualWorkout("w-1", client as unknown as QueryClient);
const [url, init] = fetchSpy.mock.calls[0]!;
expect(String(url)).toBe("/api/workouts/w-1");
expect((init as RequestInit).method).toBe("DELETE");
expect(client.removeQueries).toHaveBeenCalledWith({
queryKey: queryKeys.workoutDetail("w-1"),
});
expect(client.invalidateQueries).toHaveBeenCalledWith({
queryKey: queryKeys.workouts(),
});
});
});
@@ -0,0 +1,138 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useQueryClient, type QueryClient } from "@tanstack/react-query";
import { Loader2, Trash2 } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { apiDelete } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys, refetchInactiveDailyReads } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
/**
* Whether a workout can be deleted from its detail page: one entered by hand,
* in one's own record. A synced workout would come back with the next sync,
* and `DELETE /api/workouts/{id}` refuses it (409); the route also resolves
* the caller, so a delegate is never offered it.
*/
export function canDeleteWorkout(
workout: { source: string },
caps: { inSharedRecord: boolean },
): boolean {
return workout.source === "MANUAL" && !caps.inSharedRecord;
}
/**
* Delete, then drop the deleted detail from the cache and refresh the rest of
* the workout reads (list, dashboard tile) and the two daily reads the route
* evicted server-side. The detail is removed rather than invalidated, so
* nothing refetches a row that no longer exists.
*/
export async function deleteManualWorkout(
id: string,
queryClient: QueryClient,
): Promise<void> {
await apiDelete(`/api/workouts/${encodeURIComponent(id)}`);
queryClient.removeQueries({ queryKey: queryKeys.workoutDetail(id) });
await queryClient.invalidateQueries({ queryKey: queryKeys.workouts() });
await refetchInactiveDailyReads(queryClient);
}
/**
* The detail page's delete control: a ghost icon in the page header cluster
* that asks before it deletes. Renders nothing for a workout it may not
* delete.
*/
export function DeleteWorkoutButton({
workout,
}: {
workout: { id: string; source: string };
}) {
const { t } = useTranslations();
const router = useRouter();
const queryClient = useQueryClient();
const capabilities = useRecordCapabilities();
const [open, setOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
if (!canDeleteWorkout(workout, capabilities)) return null;
async function handleDelete(e: React.MouseEvent) {
// Keep the dialog open while the request runs; it closes on success.
e.preventDefault();
if (deleting) return;
setDeleting(true);
try {
await deleteManualWorkout(workout.id, queryClient);
toastWrittenOutcome("success", t("insights.workouts.manual.deleted"));
setOpen(false);
router.replace("/insights/workouts");
} catch {
toastWrittenOutcome("failed", t("insights.workouts.manual.deleteError"));
} finally {
setDeleting(false);
}
}
return (
<>
<Button
type="button"
variant="ghost"
size="icon"
data-slot="workout-delete"
aria-label={t("insights.workouts.manual.deleteLabel")}
title={t("insights.workouts.manual.deleteLabel")}
onClick={() => setOpen(true)}
className={cn(
"text-muted-foreground hover:text-destructive relative size-10",
"before:absolute before:-inset-1.5 before:content-['']",
)}
>
<Trash2 className="size-4" aria-hidden="true" />
</Button>
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("insights.workouts.manual.deleteConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("insights.workouts.manual.deleteConfirmDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={handleDelete}
disabled={deleting}
data-testid="workout-delete-confirm"
>
{deleting ? (
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -0,0 +1,392 @@
"use client";
import { useId, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { useQueryClient, type QueryClient } from "@tanstack/react-query";
import { Loader2, Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { DateTimeField } from "@/components/ui/date-time-field";
import { FieldError } from "@/components/forms/field-error";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useAuth } from "@/hooks/use-auth";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { ApiError, apiPost } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { getQuantityTransform } from "@/lib/measurements/display-transform";
import { queryKeys, refetchInactiveDailyReads } from "@/lib/query-keys";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import {
buildManualWorkoutEntry,
emptyManualWorkoutDraft,
newManualWorkoutExternalId,
wallClockNow,
type ManualWorkoutDraft,
type ManualWorkoutEntry,
type ManualWorkoutField,
} from "@/lib/workouts/manual-entry";
import {
workoutSportTypeEnum,
type WorkoutSportType,
} from "@/lib/validations/workout";
/**
* Log a workout by hand: sport, start, duration, and optionally distance and
* active energy. Nothing else on purpose; see `manual-entry.ts`.
*
* Same contract as the other capture forms (`MoodForm`,
* `MedicationIntakeQuickAdd`): mounted inside a `ResponsiveSheet`, the action
* row portalled into the sheet's sticky footer through `footerSlot`, and
* `onSuccess` / `onCancel` close the sheet.
*/
interface ManualWorkoutFormProps {
onSuccess?: () => void;
onCancel?: () => void;
footerSlot?: HTMLElement | null;
}
interface BatchResult {
entries?: Array<{ status: string }>;
}
export type ManualWorkoutSaveOutcome = "inserted" | "duplicate";
/**
* Post the entry and refresh what shows workouts. A `duplicate` is a success:
* the same form already landed this session (a second tap, a retry), so the
* row exists exactly once. A `skipped` entry was refused and throws.
*/
export async function saveManualWorkout(
entry: ManualWorkoutEntry,
queryClient: QueryClient,
): Promise<ManualWorkoutSaveOutcome> {
const result = await apiPost<BatchResult>("/api/workouts/batch", {
workouts: [entry],
});
const status = result?.entries?.[0]?.status;
if (status !== "inserted" && status !== "duplicate") {
throw new Error(`workout entry not stored: ${status ?? "unknown"}`);
}
// The list, the dashboard tile and every detail read sit under this
// prefix. The form also opens from the capture picker on any page, where
// the Today hero (which names a workout that just landed) and the dashboard
// snapshot are unmounted, so those two are refetched outright.
await queryClient.invalidateQueries({ queryKey: queryKeys.workouts() });
await refetchInactiveDailyReads(queryClient);
return status;
}
/** The sports, by their label in the reader's language, "Other" last. */
export function sportOptions(
label: (sport: WorkoutSportType) => string,
): Array<{ value: WorkoutSportType; label: string }> {
const named = workoutSportTypeEnum.options
.filter((sport) => sport !== "other")
.map((value): { value: WorkoutSportType; label: string } => ({
value,
label: label(value),
}))
.sort((a, b) => a.label.localeCompare(b.label));
return [...named, { value: "other", label: label("other") }];
}
export function ManualWorkoutForm({
onSuccess,
onCancel,
footerSlot,
}: ManualWorkoutFormProps) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const { user } = useAuth();
const { preference } = useUnitDisplay();
const timezone = user?.timezone || DEFAULT_TIMEZONE;
// Minted once per opened form and sent with every submit, so a second tap
// or a retried request lands as a duplicate rather than a second row.
const [externalId] = useState(newManualWorkoutExternalId);
const [draft, setDraft] = useState<ManualWorkoutDraft>(() =>
emptyManualWorkoutDraft(new Date(), timezone),
);
const [errors, setErrors] = useState<
Partial<Record<ManualWorkoutField, string>>
>({});
const [saveError, setSaveError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const formId = useId();
const ids = {
sport: `${formId}-sport`,
start: `${formId}-start`,
hours: `${formId}-hours`,
minutes: `${formId}-minutes`,
distance: `${formId}-distance`,
energy: `${formId}-energy`,
durationError: `${formId}-duration-error`,
saveError: `${formId}-save-error`,
};
const distanceUnit = getQuantityTransform("distance", preference).displayUnit;
const options = useMemo(
() => sportOptions((sport) => t(`insights.workouts.sport.${sport}`)),
[t],
);
function update<K extends keyof ManualWorkoutDraft>(
key: K,
value: ManualWorkoutDraft[K],
) {
setDraft((prev) => ({ ...prev, [key]: value }));
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (saving) return;
setSaveError(null);
const built = buildManualWorkoutEntry(draft, {
timezone,
unitPreference: preference,
externalId,
now: new Date(),
});
if (!built.ok) {
setErrors(built.errors);
return;
}
setErrors({});
setSaving(true);
try {
const outcome = await saveManualWorkout(built.entry, queryClient);
// A duplicate wrote nothing new: the same form already landed this
// session. That is reported as such, not as a fresh save.
if (outcome === "duplicate") {
toastWrittenOutcome(
"empty",
t("insights.workouts.manual.alreadySaved"),
);
} else {
toastWrittenOutcome("success", t("common.saved"));
}
onSuccess?.();
} catch (err) {
setSaveError(
err instanceof ApiError && err.message
? err.message
: t("insights.workouts.manual.saveError"),
);
} finally {
setSaving(false);
}
}
const fieldError = (field: ManualWorkoutField) =>
errors[field] ? t(errors[field]) : undefined;
const describedBy = (field: ManualWorkoutField, id: string) =>
errors[field] ? id : undefined;
const footerNode = (
<div className="flex w-full items-center justify-end gap-2">
{onCancel && (
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={saving}
className="min-h-11 sm:min-h-9"
>
{t("common.cancel")}
</Button>
)}
<Button
type="submit"
form={formId}
disabled={saving}
className="min-h-11 sm:min-h-9"
data-testid="manual-workout-save"
>
{saving ? (
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
) : (
<Plus className="size-4" />
)}
{t("common.save")}
</Button>
</div>
);
return (
<form
id={formId}
onSubmit={handleSubmit}
noValidate
className="space-y-4"
data-testid="manual-workout-form"
>
<div className="space-y-2">
<Label htmlFor={ids.sport}>
{t("insights.workouts.manual.sportLabel")}
</Label>
<Select
value={draft.sportType}
onValueChange={(value) =>
update("sportType", value as WorkoutSportType)
}
>
<SelectTrigger
id={ids.sport}
className="w-full"
aria-invalid={!!errors.sportType || undefined}
aria-describedby={describedBy("sportType", `${ids.sport}-error`)}
data-testid="manual-workout-sport"
>
<SelectValue
placeholder={t("insights.workouts.manual.sportPlaceholder")}
/>
</SelectTrigger>
<SelectContent>
{options.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError
id={`${ids.sport}-error`}
message={fieldError("sportType")}
/>
</div>
<div className="space-y-2">
<Label htmlFor={ids.start}>
{t("insights.workouts.manual.startLabel")}
</Label>
<DateTimeField
id={ids.start}
value={draft.start}
onChange={(value) => update("start", value)}
max={wallClockNow(new Date(), timezone)}
required
aria-required="true"
aria-invalid={!!errors.start || undefined}
aria-describedby={describedBy("start", `${ids.start}-error`)}
data-testid="manual-workout-start"
/>
<FieldError id={`${ids.start}-error`} message={fieldError("start")} />
</div>
<fieldset
className="space-y-2"
aria-describedby={describedBy("duration", ids.durationError)}
>
<legend className="mb-2 text-sm leading-none font-medium">
{t("insights.workouts.manual.durationLabel")}
</legend>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label
htmlFor={ids.hours}
className="text-muted-foreground text-xs font-normal"
>
{t("common.hours")}
</Label>
<Input
id={ids.hours}
inputMode="numeric"
autoComplete="off"
value={draft.hours}
onChange={(e) => update("hours", e.target.value)}
aria-invalid={!!errors.duration || undefined}
data-testid="manual-workout-hours"
/>
</div>
<div className="space-y-1.5">
<Label
htmlFor={ids.minutes}
className="text-muted-foreground text-xs font-normal"
>
{t("common.minutes")}
</Label>
<Input
id={ids.minutes}
inputMode="numeric"
autoComplete="off"
value={draft.minutes}
onChange={(e) => update("minutes", e.target.value)}
aria-invalid={!!errors.duration || undefined}
data-testid="manual-workout-minutes"
/>
</div>
</div>
<FieldError id={ids.durationError} message={fieldError("duration")} />
</fieldset>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={ids.distance}>
{t("insights.workouts.manual.distanceLabel", {
unit: distanceUnit,
})}
</Label>
<Input
id={ids.distance}
inputMode="decimal"
autoComplete="off"
placeholder={t("common.optional")}
value={draft.distance}
onChange={(e) => update("distance", e.target.value)}
aria-invalid={!!errors.distance || undefined}
aria-describedby={describedBy("distance", `${ids.distance}-error`)}
data-testid="manual-workout-distance"
/>
<FieldError
id={`${ids.distance}-error`}
message={fieldError("distance")}
/>
</div>
<div className="space-y-2">
<Label htmlFor={ids.energy}>
{t("insights.workouts.manual.energyLabel")}
</Label>
<Input
id={ids.energy}
inputMode="decimal"
autoComplete="off"
placeholder={t("common.optional")}
value={draft.energyKcal}
onChange={(e) => update("energyKcal", e.target.value)}
aria-invalid={!!errors.energyKcal || undefined}
aria-describedby={describedBy("energyKcal", `${ids.energy}-error`)}
data-testid="manual-workout-energy"
/>
<FieldError
id={`${ids.energy}-error`}
message={fieldError("energyKcal")}
/>
</div>
</div>
{saveError && (
<div
id={ids.saveError}
role="alert"
aria-live="assertive"
className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm"
>
{saveError}
</div>
)}
{footerSlot ? createPortal(footerNode, footerSlot) : footerNode}
</form>
);
}
@@ -0,0 +1,94 @@
"use client";
import { useState } from "react";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { sheetBodyHasUnsavedInput } from "@/components/dashboard/quick-entry-sheets";
import { ManualWorkoutForm } from "@/components/workouts/manual-workout-form";
import { useTranslations } from "@/lib/i18n/context";
/**
* The "Log workout" sheet for a surface that owns its own open state (the
* workouts page header). The dashboard quick-add and the capture picker mount
* `ManualWorkoutForm` inside their own sheets, which already carry the same
* confirm-before-discard guard; this one carries it too, so a half-filled
* form is never dropped by a stray swipe.
*/
export function ManualWorkoutSheet({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { t } = useTranslations();
const [footerEl, setFooterEl] = useState<HTMLDivElement | null>(null);
const [confirmDiscardOpen, setConfirmDiscardOpen] = useState(false);
function handleOpenChange(next: boolean) {
if (next) return onOpenChange(true);
if (sheetBodyHasUnsavedInput()) {
setConfirmDiscardOpen(true);
return;
}
onOpenChange(false);
}
return (
<>
<ResponsiveSheet
open={open}
onOpenChange={handleOpenChange}
title={t("insights.workouts.manual.sheetTitle")}
description={t("insights.workouts.manual.sheetDescription")}
footer={<div ref={setFooterEl} className="flex w-full" />}
>
{open && (
<ManualWorkoutForm
onSuccess={() => onOpenChange(false)}
onCancel={() => onOpenChange(false)}
footerSlot={footerEl}
/>
)}
</ResponsiveSheet>
<AlertDialog
open={confirmDiscardOpen}
onOpenChange={setConfirmDiscardOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("dashboard.quickEntryDiscard.title")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("dashboard.quickEntryDiscard.description")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("dashboard.quickEntryDiscard.cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setConfirmDiscardOpen(false);
onOpenChange(false);
}}
>
{t("dashboard.quickEntryDiscard.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}