release: v1.32.0 — hardening and scalability (#580)

* release: prepare v1.32.0

* test: stabilize water capture re-entry
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-22 04:14:15 +02:00
1 parent b01822be48
commit 62eb431d66
372 files changed
+25178 -8561

No files matched your search

@@ -47,9 +47,9 @@ vi.mock("@/hooks/use-auth", () => ({
import { I18nProvider } from "@/lib/i18n/context";
import { BackupsSection } from "../backups-section";
function render() {
function render(locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<I18nProvider initialLocale={locale}>
<BackupsSection />
</I18nProvider>,
);
@@ -79,6 +79,25 @@ describe("BackupsSection — empty state", () => {
expect(matches).toBeGreaterThanOrEqual(2);
});
it("uses the full muted semantic color for localized upload help", () => {
const english = render();
const german = render("de");
const helpElement = english.match(
/<[^>]+data-slot="backup-upload-help"[^>]*>/,
)?.[0];
const className = helpElement?.match(/\bclass="([^"]*)"/)?.[1] ?? "";
expect(english).toContain(
"JSON file matching the current backup schema. Max 10 MB.",
);
expect(german).toContain(
"JSON-Datei passend zum aktuellen Backup-Schema. Max. 10 MB.",
);
expect(helpElement).toBeDefined();
expect(className.split(/\s+/)).toContain("text-muted-foreground");
expect(className).not.toMatch(/\b(?:opacity-\d+|text-\S+\/\d+)\b/);
});
it("warns in both catalogs that restore overwrites instance-wide settings", () => {
expect(de.admin.section.backups.restoreDescription).toMatch(
/instanzweite Einstellungen/i,
+4 -5
View File
@@ -372,16 +372,15 @@ export function AdminShell({ active, children }: AdminShellProps) {
</ul>
</aside>
{/* Main column (cards) — row 2 / col 2, the grid's `1fr` row, so it
{/* Cards column — row 2 / col 2, the grid's `1fr` row, so it
stretches to fill the shared `SUB_SHELL_GRID_FLOOR` on the grid
(matches `<SettingsShell>`) without over-shooting. The loading-jump
floor lives on the GRID, not here: a column-level
`min-h-[calc(100dvh-12rem)]` reserve (the pre-#154 shape this used
to carry) over-reserves on short sections and on mobile, scrolling
the page into a dark band below the last card. */}
<main className="min-w-0 md:col-start-2 md:row-start-2">
{children}
</main>
the page into a dark band below the last card. AuthShell already
owns the page's single `<main>` landmark. */}
<div className="min-w-0 md:col-start-2 md:row-start-2">{children}</div>
</div>
</div>
);
+4 -1
View File
@@ -414,7 +414,10 @@ export function BackupsSection() {
</div>
<p className="text-muted-foreground text-xs">
{t("admin.section.backups.uploadDescription")}{" "}
<span className="opacity-80">
<span
data-slot="backup-upload-help"
className="text-muted-foreground"
>
{t("admin.section.backups.uploadHelp")}
</span>
</p>
@@ -95,7 +95,7 @@ export function CustomMetricEntryForm({
queryKey: queryKeys.customMetricDetail(customMetricId),
});
queryClient.invalidateQueries({
queryKey: ["custom-metric-entries", customMetricId],
queryKey: queryKeys.customMetricEntriesPrefix(customMetricId),
});
onSuccess?.();
} catch (err) {
@@ -83,7 +83,7 @@ export function CustomMetricHistoryList({
queryKey: queryKeys.customMetricDetail(customMetricId),
});
queryClient.invalidateQueries({
queryKey: ["custom-metric-entries", customMetricId],
queryKey: queryKeys.customMetricEntriesPrefix(customMetricId),
});
}
@@ -16,6 +16,27 @@ import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
const moduleGate = vi.hoisted(() => ({ nutrientsEnabled: true }));
vi.mock("@/hooks/use-module-enabled", () => ({
useModuleEnabled: (moduleKey: string) =>
moduleKey === "nutrients" ? moduleGate.nutrientsEnabled : true,
}));
vi.mock("@/components/ui/dropdown-menu", () => ({
DropdownMenu: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
DropdownMenuItem: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { username: "tester", timezone: "Europe/Berlin" },
@@ -55,3 +76,30 @@ describe("<DashboardHeader> — greeting", () => {
expect(html).toMatch(/<h1[^>]*>Dashboard<\/h1>/);
});
});
describe("<DashboardHeader> — nutrients module gate", () => {
it("hides only the water quick-add item when nutrients are disabled", () => {
moduleGate.nutrientsEnabled = false;
const html = renderSSR(
<DashboardHeader onQuickEntry={() => undefined} />,
"en",
);
expect(html).not.toContain("Log water");
expect(html).toContain("Log measurement");
expect(html).toContain("Log mood");
expect(html).toContain("Log medication intake");
});
it("keeps the water quick-add item available when nutrients are enabled", () => {
moduleGate.nutrientsEnabled = true;
const html = renderSSR(
<DashboardHeader onQuickEntry={() => undefined} />,
"en",
);
expect(html).toContain("Log water");
});
});
@@ -50,9 +50,9 @@ vi.mock("@/hooks/use-auth", () => ({
import {
MedicationIntakeQuickAdd,
pickDefaultMedicationId,
type MedicationOption,
} from "../medication-intake-quick-add";
import { pickDefaultMedicationId } from "@/lib/medications/default-medication";
function makeMed(
id: string,
@@ -273,6 +273,41 @@ describe("<MedicationIntakeQuickAdd> — SSR contract", () => {
).toBeGreaterThanOrEqual(1);
});
it("renders the current-window medication as selected ahead of array order", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-12T08:00:00.000Z"));
mockUseQuery.mockReturnValue({
data: [
makeMed("first", { name: "Alpha" }),
makeMed("due", {
name: "Zulu",
nextDueAt: "2026-05-12T07:00:00.000Z",
nextDueOverdue: false,
schedules: [
{
id: "due-schedule",
windowStart: "08:00",
windowEnd: "11:00",
daysOfWeek: null,
label: null,
dose: null,
},
],
}),
],
isLoading: false,
});
try {
const html = renderSSR(<MedicationIntakeQuickAdd />);
expect(html).toMatch(
/data-slot="select-value"[^>]*>[\s\S]*?Zulu[\s\S]*?<\/span>/,
);
} finally {
vi.useRealTimers();
}
});
it("does not leak raw i18n keys in either locale", () => {
mockUseQuery.mockReturnValue({ data: [], isLoading: false });
const en = renderSSR(<MedicationIntakeQuickAdd />, "en");
@@ -12,7 +12,7 @@
*/
import { useMemo } from "react";
import Link from "next/link";
import { Activity, Pill, Plus, Waves, Wrench } from "lucide-react";
import { Activity, GlassWater, Pill, Plus, Waves, Wrench } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@@ -24,6 +24,7 @@ import { PageHeader } from "@/components/ui/page-header";
import { useTranslations } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { useModuleEnabled } from "@/hooks/use-module-enabled";
import { getHourForTimeZone } from "@/components/dashboard/range-display";
import type { QuickEntryDialog } from "@/components/dashboard/quick-entry-sheets";
@@ -35,6 +36,7 @@ export function DashboardHeader({
const { t } = useTranslations();
const { user } = useAuth();
const mounted = useMounted();
const nutrientsEnabled = useModuleEnabled("nutrients");
// The pre-hero greeting derivation, kept hydration-safe: `user` comes
// from the auth query, which can resolve before this boundary
@@ -153,6 +155,12 @@ export function DashboardHeader({
<Pill className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddMedicationIntake")}
</DropdownMenuItem>
{nutrientsEnabled && (
<DropdownMenuItem onClick={() => onQuickEntry("water")}>
<GlassWater className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddWater")}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</>
@@ -23,10 +23,11 @@ import {
medicationDependentKeys,
queryKeys,
} from "@/lib/query-keys";
import type { ScheduleWindowInput } from "@/lib/medications/window-status";
import {
reduceCurrentWindowStatus,
type ScheduleWindowInput,
} from "@/lib/medications/window-status";
resolveMedicationSelectionId,
type DefaultMedicationOption,
} from "@/lib/medications/default-medication";
import { useAuth } from "@/hooks/use-auth";
import { ApiError, apiGet, apiPost } from "@/lib/api/api-fetch";
import { runUndoIntake } from "@/components/medications/use-medication-intake";
@@ -73,23 +74,9 @@ interface Schedule extends ScheduleWindowInput {
dose: string | null;
}
export interface MedicationOption {
id: string;
name: string;
export interface MedicationOption extends DefaultMedicationOption {
dose: string;
active: boolean;
schedules: Schedule[];
lastTakenAt: string | null;
todayEventCount: number | null;
/**
* v1.16.9 — the server display-due verdict from `GET /api/medications`
* (`nextDueAt` + `nextDueOverdue`). Threaded into the default pick so
* the quick-add gates its "due now" heuristic exactly like the cards:
* a rolling cadence whose next dose is days away must not pre-select.
* Optional for older mocks; absent keeps the legacy band-only pick.
*/
nextDueAt?: string | null;
nextDueOverdue?: boolean;
}
interface MedicationIntakeQuickAddProps {
@@ -112,67 +99,6 @@ function getDefaultIntakeAtValue(): string {
return local.toISOString().slice(0, 16);
}
/**
* Pick the medication that should be pre-selected when the sheet opens.
*
* Priority:
* 1. The active medication whose schedule window is currently open
* (or late / very_late) — this is the "due now" surface.
* 2. The single active medication when the user has exactly one.
* 3. The first active medication alphabetically (stable fallback).
*
* Returns the medication id or `null` when no active medication exists.
*/
export function pickDefaultMedicationId(
options: MedicationOption[],
now: Date = new Date(),
thresholds: { lateMinutes: number; missedMinutes: number } = {
lateMinutes: 120,
missedMinutes: 240,
},
/**
* v1.16.9 — the profile timezone the "due now" heuristic reasons in
* (the same source the cards use). Berlin stays the last-resort
* fallback for logged-out mounts and legacy fixtures.
*/
tz: string = "Europe/Berlin",
): string | null {
const actives = options.filter((m) => m.active);
if (actives.length === 0) return null;
if (actives.length === 1) return actives[0].id;
const due = actives.find((m) => {
// v1.16.9 — the same server display-due gate the cards apply: a
// future (non-overdue) next-due suppresses the overdue tiers and a
// day-scale dose taken early in its period must not pre-select.
const nextDueMs = m.nextDueAt ? new Date(m.nextDueAt).getTime() : NaN;
const status = reduceCurrentWindowStatus({
schedules: m.schedules,
now,
lateMinutes: thresholds.lateMinutes,
missedMinutes: thresholds.missedMinutes,
active: true,
lastTakenAt: m.lastTakenAt,
todayEventCount: m.todayEventCount ?? 0,
tz,
nextDue:
m.nextDueAt === undefined
? undefined
: Number.isFinite(nextDueMs)
? { at: new Date(nextDueMs), overdue: m.nextDueOverdue === true }
: null,
});
return status.status !== null && status.takenEarlyDaysAgo === null;
});
if (due) return due.id;
// Stable alphabetical fallback when nothing is currently due.
const sorted = [...actives].sort((a, b) =>
a.name.localeCompare(b.name, "de", { sensitivity: "base" }),
);
return sorted[0]?.id ?? null;
}
export function MedicationIntakeQuickAdd({
onSuccess,
onCancel,
@@ -235,16 +161,20 @@ export function MedicationIntakeQuickAdd({
const [takenAt, setTakenAt] = useState<string>(getDefaultIntakeAtValue);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectionNow] = useState(() => new Date());
const errorId = useId();
const errorDescriptor = error ? errorId : undefined;
const formId = useId();
const defaultMedicationId = useMemo(
() => pickDefaultMedicationId(medications, new Date(), undefined, userTz),
[medications, userTz],
);
const medicationId = medicationOverride ?? defaultMedicationId ?? "";
const medicationId =
resolveMedicationSelectionId(
medications,
medicationOverride,
selectionNow,
undefined,
userTz,
) ?? "";
const selectedMedication = medications.find((m) => m.id === medicationId);
const dose = doseOverride ?? selectedMedication?.dose ?? "";
@@ -431,7 +361,22 @@ export function MedicationIntakeQuickAdd({
placeholder={t(
"dashboard.medicationIntakeQuickAdd.medicationPlaceholder",
)}
/>
>
{selectedMedication ? (
<span className="flex items-center gap-2">
<Pill
className="text-muted-foreground h-4 w-4"
aria-hidden="true"
/>
<span>{selectedMedication.name}</span>
{selectedMedication.dose ? (
<span className="text-muted-foreground text-xs">
({selectedMedication.dose})
</span>
) : null}
</span>
) : null}
</SelectValue>
</SelectTrigger>
<SelectContent>
{sortedMedications.map((m) => (
@@ -1,8 +1,8 @@
"use client";
/**
* Quick-entry sheets for the dashboard: measurement, mood, and
* medication-intake forms behind the shared ResponsiveSheet primitive
* Quick-entry sheets for the dashboard: measurement, mood, medication
* intake, and water behind the shared ResponsiveSheet primitive
* (bottom-sheet on `<md`, centred Dialog on `md+`), plus the
* confirm-before-discard guard for dirty forms.
*
@@ -24,10 +24,11 @@ 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 { WaterQuickAddSheet } from "@/components/insights/nutrients/water-quick-add-sheet";
import { useTranslations } from "@/lib/i18n/context";
export type QuickEntryDialog =
"measurement" | "mood" | "medicationIntake" | null;
"measurement" | "mood" | "medicationIntake" | "water" | null;
/**
* v1.11.3 F3 — guard the quick-entry sheets against an accidental
@@ -173,6 +174,11 @@ export function QuickEntrySheets({
footerSlot={medicationIntakeFooterEl}
/>
</ResponsiveSheet>
<WaterQuickAddSheet
open={open === "water"}
onOpenChange={handleQuickEntryOpenChange}
onSuccess={onClose}
/>
{/* v1.11.3 F3 — confirm before discarding a partly-filled
quick-entry form when the sheet is dismissed by an overlay
@@ -27,6 +27,17 @@ function render(node: React.ReactNode) {
const noop = () => {};
function buttonMarkupByName(html: string, name: string): string {
const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const markup = html.match(
new RegExp(
`<button(?=[^>]*aria-label="${escapedName}")[^>]*>[\\s\\S]*?</button>`,
),
)?.[0];
expect(markup).toBeDefined();
return markup ?? "";
}
const suggestion = (
overrides: Partial<DocumentSuggestionDto> = {},
): DocumentSuggestionDto => ({
@@ -112,6 +123,27 @@ describe("<AssistSuggestionReview>", () => {
);
expect(html).toContain("Nothing could be read from this document");
});
it("keeps dismiss compact while providing a 44px mobile target", () => {
const html = render(
<AssistSuggestionReview
suggestion={suggestion()}
kindLabel="Lab result"
dateLabel="1 Mar 2026"
applied={{ title: false, kind: false, date: false }}
onUseTitle={noop}
onUseKind={noop}
onUseDate={noop}
onDismiss={noop}
/>,
);
const dismiss = buttonMarkupByName(html, "Dismiss");
expect(dismiss).toContain("size-11");
expect(dismiss).toContain("sm:size-7");
expect(dismiss).toContain('aria-label="Dismiss"');
expect(dismiss).toContain("<svg");
expect(dismiss).toContain("size-4");
});
});
describe("<DocumentSummaryPanel>", () => {
@@ -170,4 +202,22 @@ describe("<DocumentSummaryPanel>", () => {
expect(html).toContain('role="alert"');
expect(html).toContain("read the document");
});
it("keeps close compact while providing a 44px mobile target", () => {
const html = render(
<DocumentSummaryPanel
output="summary"
result={{ summary: "A lipid panel dated 1 March 2026." }}
isPending={false}
errorKey={null}
onClose={noop}
/>,
);
const close = buttonMarkupByName(html, "Hide");
expect(close).toContain("size-11");
expect(close).toContain("sm:size-7");
expect(close).toContain('aria-label="Hide"');
expect(close).toContain("<svg");
expect(close).toContain("size-4");
});
});
@@ -0,0 +1,176 @@
import { describe, expect, it } from "vitest";
import {
closeDocumentSelectionHistoryEntry,
closeDocumentSelectionAfterCoachHandoff,
documentSelectionHistoryState,
documentSelectionHref,
withoutDocumentSelectionHref,
} from "../documents-view";
class MemoryHistory {
private entries: { href: string; state: unknown }[];
private index: number;
constructor(entries: string[]) {
this.entries = entries.map((href) => ({ href, state: null }));
this.index = entries.length - 1;
}
get href() {
return this.entries[this.index]?.href;
}
get state() {
return this.entries[this.index]?.state;
}
pushState(state: unknown, _unused: string, href: string | URL) {
this.entries.splice(this.index + 1, Infinity, {
href: href.toString(),
state,
});
this.index += 1;
}
replaceState(state: unknown, _unused: string, href?: string | URL | null) {
this.entries[this.index] = {
href: href?.toString() ?? this.href!,
state,
};
}
back() {
if (this.index > 0) this.index -= 1;
}
}
describe("document vault URL selection", () => {
it("adds an ordinary card selection without dropping unrelated query state", () => {
expect(
documentSelectionHref(
"/documents",
"episode=episode-1&view=compact&q=MRT",
"doc_123",
),
).toBe("/documents?episode=episode-1&view=compact&q=MRT&doc=doc_123");
});
it("replaces an existing selection instead of appending a duplicate", () => {
expect(
documentSelectionHref(
"/documents",
"episode=episode-1&doc=old-document",
"new-document",
),
).toBe("/documents?episode=episode-1&doc=new-document");
});
it.each(["", "../coach", "doc?id=other", "doc id", "doc\n", "a".repeat(41)])(
"refuses to write an unsafe document id: %j",
(documentId) => {
expect(
documentSelectionHref(
"/documents",
"episode=episode-1&view=compact",
documentId,
),
).toBeNull();
},
);
it("removes only the document selection on close", () => {
expect(
withoutDocumentSelectionHref(
"/documents",
"episode=episode-1&doc=doc_123&view=compact&q=MRT",
),
).toBe("/documents?episode=episode-1&view=compact&q=MRT");
});
it("returns the pathname when doc was the only query parameter", () => {
expect(withoutDocumentSelectionHref("/documents", "doc=doc_123")).toBe(
"/documents",
);
});
it("consumes a card-open entry so the next Back reaches the prior distinct page", () => {
const history = new MemoryHistory(["/today", "/documents?episode=ep-1"]);
const openHref = documentSelectionHref(
"/documents",
"episode=ep-1",
"doc-1",
);
expect(openHref).not.toBeNull();
history.pushState(documentSelectionHistoryState("doc-1"), "", openHref!);
closeDocumentSelectionHistoryEntry(
history,
"/documents",
"episode=ep-1&doc=doc-1",
"doc-1",
);
expect(history.href).toBe("/documents?episode=ep-1");
history.back();
expect(history.href).toBe("/today");
});
it("consumes a dismissed Coach handoff and closes its card-owned detail entry", () => {
const history = new MemoryHistory(["/today", "/documents?episode=ep-1"]);
history.pushState(
documentSelectionHistoryState("doc-1"),
"",
"/documents?episode=ep-1&doc=doc-1",
);
const retainedHandoff = closeDocumentSelectionAfterCoachHandoff(
history,
"/documents",
"episode=ep-1&doc=doc-1",
"doc-1",
"doc-1",
"dismiss",
);
expect(retainedHandoff).toBeNull();
expect(history.href).toBe("/documents?episode=ep-1");
});
it("retains a navigating Coach handoff for maximize and browser Back", () => {
const history = new MemoryHistory(["/today", "/documents?episode=ep-1"]);
history.pushState(
documentSelectionHistoryState("doc-1"),
"",
"/documents?episode=ep-1&doc=doc-1",
);
const retainedHandoff = closeDocumentSelectionAfterCoachHandoff(
history,
"/documents",
"episode=ep-1&doc=doc-1",
"doc-1",
"doc-1",
"navigate",
);
expect(retainedHandoff).toBe("doc-1");
expect(history.href).toBe("/documents?episode=ep-1&doc=doc-1");
});
it("replaces a direct deep-link selection without navigating away", () => {
const history = new MemoryHistory([
"/today",
"/documents?episode=ep-1&doc=doc-1",
]);
history.replaceState({ __NA: true }, "", history.href);
closeDocumentSelectionHistoryEntry(
history,
"/documents",
"episode=ep-1&doc=doc-1",
"doc-1",
);
expect(history.href).toBe("/documents?episode=ep-1");
});
});
@@ -147,7 +147,7 @@ export function AssistSuggestionReview({
type="button"
variant="ghost"
size="icon"
className="size-7 shrink-0"
className="size-11 shrink-0 sm:size-7"
onClick={onDismiss}
aria-label={t("documents.assist.dismiss")}
>
@@ -233,7 +233,7 @@ export function DocumentSummaryPanel({
type="button"
variant="ghost"
size="icon"
className="size-7 shrink-0"
className="size-11 shrink-0 sm:size-7"
onClick={onClose}
aria-label={t("documents.summary.close")}
>
+165 -30
View File
@@ -42,6 +42,10 @@ import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
import { apiGet, apiPost } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import {
type CoachCloseIntent,
useCoachLaunch,
} from "@/lib/insights/coach-launch-context";
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
import type { DocumentVaultFilters } from "@/lib/query-keys/documents";
import {
@@ -79,6 +83,99 @@ interface ListPage {
nextCursor: string | null;
}
// Strict end-of-input: `$` also accepts a trailing newline in JavaScript.
const DOCUMENT_QUERY_ID_PATTERN = /^[a-zA-Z0-9_-]{1,40}(?![\s\S])/;
const DOCUMENT_SELECTION_HISTORY_KEY = "__healthlogPushedDocumentSelection";
export function documentSelectionHistoryState(
documentId: string,
): Record<string, unknown> {
// Pass only application state here. Next's patched pushState copies its
// private router fields onto this object; copying history.state ourselves
// would include `__NA` and make that patch treat this as an internal write,
// skipping useSearchParams synchronization.
return { [DOCUMENT_SELECTION_HISTORY_KEY]: documentId };
}
export function documentSelectionHref(
pathname: string,
currentSearch: string,
documentId: string,
): string | null {
if (!DOCUMENT_QUERY_ID_PATTERN.test(documentId)) return null;
const next = new URLSearchParams(currentSearch);
next.set("doc", documentId);
const search = next.toString();
return search ? `${pathname}?${search}` : pathname;
}
export function withoutDocumentSelectionHref(
pathname: string,
currentSearch: string,
): string {
const next = new URLSearchParams(currentSearch);
next.delete("doc");
const search = next.toString();
return search ? `${pathname}?${search}` : pathname;
}
export function closeDocumentSelectionHistoryEntry(
history: {
readonly state: unknown;
back: () => void;
replaceState: (
data: unknown,
unused: string,
url?: string | URL | null,
) => void;
},
pathname: string,
currentSearch: string,
documentId: string,
): void {
const ownsCurrentEntry =
history.state !== null &&
typeof history.state === "object" &&
(history.state as Record<string, unknown>)[
DOCUMENT_SELECTION_HISTORY_KEY
] === documentId;
if (ownsCurrentEntry) {
// Card selection owns the current pushed entry. Returning to its base
// consumes it, so the user's next Back reaches the prior distinct page.
history.back();
return;
}
// A deep link did not create a disposable entry on this page. Keep the user
// here and remove only the selection from the current URL.
history.replaceState(
null,
"",
withoutDocumentSelectionHref(pathname, currentSearch),
);
}
export function closeDocumentSelectionAfterCoachHandoff(
history: Parameters<typeof closeDocumentSelectionHistoryEntry>[0],
pathname: string,
currentSearch: string,
documentId: string,
handedOffDocumentId: string | null,
closeIntent: CoachCloseIntent | null | undefined,
): string | null {
if (handedOffDocumentId === documentId && closeIntent === "navigate") {
return handedOffDocumentId;
}
closeDocumentSelectionHistoryEntry(
history,
pathname,
currentSearch,
documentId,
);
return handedOffDocumentId === documentId ? null : handedOffDocumentId;
}
export function DocumentsView() {
const { t } = useTranslations();
const { user, isLoading: authLoading, isAuthenticated } = useAuth();
@@ -86,6 +183,7 @@ export function DocumentsView() {
const pathname = usePathname();
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const coachLaunch = useCoachLaunch();
const moduleEnabled = user?.modules?.inboundDocuments === true;
@@ -540,42 +638,79 @@ export function DocumentsView() {
// ── Detail sheet ──────────────────────────────────────────────────────
const [detailId, setDetailId] = useState<string | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
const openDetail = useCallback((id: string) => {
setDetailId(id);
setDetailOpen(true);
}, []);
const handedOffDocumentRef = useRef<string | null>(null);
// `?doc=<id>` deep link (used by the illness page's document rows): open
// the detail sheet once per value; closing the sheet strips the param so
// back/forward and refresh behave. Render-phase derived-state adjustment
// (the file's established pattern), consumed only once auth has resolved
// so a deep link never burns before the module flag is known. The id is
// shape-checked before it is interpolated into an API path.
const docParam = searchParams.get("doc");
const [consumedDocParam, setConsumedDocParam] = useState<string | null>(null);
if (!authLoading && moduleEnabled && docParam !== consumedDocParam) {
setConsumedDocParam(docParam);
if (docParam !== null && /^[a-zA-Z0-9_-]{1,40}$/.test(docParam)) {
setDetailId(docParam);
setDetailOpen(true);
}
const rawDocParam = searchParams.get("doc");
const docParam =
rawDocParam !== null && DOCUMENT_QUERY_ID_PATTERN.test(rawDocParam)
? rawDocParam
: null;
const [observedDocParam, setObservedDocParam] = useState<string | null>(null);
// The query is the navigation owner: direct links, browser back, and browser
// forward all drive the same detail selection without remounting the route.
// Wait for auth so a deep link cannot be consumed before the module gate is
// known, and never interpolate an unvalidated id into the detail API path.
if (!authLoading && moduleEnabled && rawDocParam !== observedDocParam) {
setObservedDocParam(rawDocParam);
setDetailId(docParam);
setDetailOpen(docParam !== null);
}
const handleDetailOpenChange = useCallback(
(open: boolean) => {
setDetailOpen(open);
if (!open && searchParams.get("doc")) {
const sp = new URLSearchParams(searchParams.toString());
sp.delete("doc");
const search = sp.toString();
router.replace(search ? `${pathname}?${search}` : pathname, {
scroll: false,
});
}
const openDetail = useCallback(
(id: string) => {
const href = documentSelectionHref(pathname, searchParams.toString(), id);
if (href === null) return;
handedOffDocumentRef.current = null;
setDetailId(id);
setDetailOpen(true);
window.history.pushState(documentSelectionHistoryState(id), "", href);
},
[pathname, router, searchParams],
[pathname, searchParams],
);
const handleDetailOpenChange = useCallback((open: boolean) => {
setDetailOpen(open);
}, []);
const coachOwnsDocument =
docParam !== null &&
coachLaunch?.open === true &&
coachLaunch.documentId === docParam;
useEffect(() => {
if (authLoading || !moduleEnabled) return;
if (docParam === null) {
handedOffDocumentRef.current = null;
return;
}
if (detailOpen) return;
if (coachOwnsDocument) {
// "Ask Coach" intentionally closes the sheet while the drawer owns the
// same document. Keep the return URL intact so maximize → browser-back
// can reconstruct the sheet from `?doc=`.
handedOffDocumentRef.current = docParam;
return;
}
handedOffDocumentRef.current = closeDocumentSelectionAfterCoachHandoff(
window.history,
pathname,
searchParams.toString(),
docParam,
handedOffDocumentRef.current,
coachLaunch?.closeIntent,
);
}, [
authLoading,
coachOwnsDocument,
coachLaunch?.closeIntent,
detailOpen,
docParam,
moduleEnabled,
pathname,
searchParams,
]);
// Hover/focus intent prefetches the detail METADATA (never the blob —
// the blob fetch starts when the sheet mounts its preview element).
const prefetchDetail = useCallback(
@@ -0,0 +1,353 @@
import { readFileSync } from "node:fs";
import { describe, expect, it, vi } from "vitest";
import {
createSeededFormDismissalController,
createSeededFormHistoryTraversalGuard,
safeInternalNavigationTarget,
} from "../use-seeded-form-dismissal";
describe("seeded form dismissal", () => {
const measurementSeed = {
value: "72.4",
measuredAt: "2026-07-21T08:30",
notes: "before breakfast",
};
it("closes a pristine edit immediately", () => {
const close = vi.fn();
const controller = createSeededFormDismissalController({
seed: measurementSeed,
value: measurementSeed,
});
controller.requestClose(close);
expect(close).toHaveBeenCalledOnce();
expect(controller.getState()).toEqual({
isDirty: false,
discardDialogOpen: false,
});
});
it("asks before closing an edit changed from its seeded values", () => {
const close = vi.fn();
const controller = createSeededFormDismissalController({
seed: measurementSeed,
value: { ...measurementSeed, value: "73.1" },
});
controller.requestClose(close);
expect(close).not.toHaveBeenCalled();
expect(controller.getState()).toEqual({
isDirty: true,
discardDialogOpen: true,
});
});
it("keeps the changed draft when discard is cancelled", () => {
const close = vi.fn();
const changed = { ...measurementSeed, notes: "after breakfast" };
const controller = createSeededFormDismissalController({
seed: measurementSeed,
value: changed,
});
controller.requestClose(close);
controller.cancelDiscard();
expect(close).not.toHaveBeenCalled();
expect(controller.getState()).toEqual({
isDirty: true,
discardDialogOpen: false,
});
});
it("closes and discards only after explicit confirmation", () => {
const close = vi.fn();
const controller = createSeededFormDismissalController({
seed: measurementSeed,
value: { ...measurementSeed, measuredAt: "2026-07-21T09:15" },
});
controller.requestClose(close);
controller.confirmDiscard();
expect(close).toHaveBeenCalledOnce();
expect(controller.getState().discardDialogOpen).toBe(false);
});
it("treats a successfully saved profile value as the new clean seed", () => {
const close = vi.fn();
const profileSeed = {
email: "person@example.test",
heightCm: "175",
timezone: "Europe/Berlin",
};
const savedProfile = { ...profileSeed, timezone: "Europe/London" };
const controller = createSeededFormDismissalController({
seed: profileSeed,
value: savedProfile,
});
expect(controller.getState().isDirty).toBe(true);
controller.sync({ seed: savedProfile, value: savedProfile });
controller.requestClose(close);
expect(controller.getState().isDirty).toBe(false);
expect(close).toHaveBeenCalledOnce();
});
it("does not dismiss while a write is pending", () => {
const close = vi.fn();
const controller = createSeededFormDismissalController({
seed: measurementSeed,
value: { ...measurementSeed, value: "73.1" },
blocked: true,
});
controller.requestClose(close);
controller.confirmDiscard();
expect(close).not.toHaveBeenCalled();
expect(controller.getState().discardDialogOpen).toBe(false);
});
});
describe("seeded form Back navigation", () => {
function createHistoryHarness() {
let index = 1;
let entries: unknown[] = [{}, {}];
let onPopState:
((event: { stopImmediatePropagation(): void }) => void) | null = null;
const events: Array<{ stopImmediatePropagation(): void }> = [];
const history = {
get state() {
return entries[index];
},
go(delta: number) {
const nextIndex = index + delta;
if (nextIndex < 0 || nextIndex >= entries.length) return;
index = nextIndex;
const event = { stopImmediatePropagation: vi.fn() };
events.push(event);
onPopState?.(event);
},
pushState(state: unknown, unused?: string, url?: string) {
void unused;
void url;
entries = entries.slice(0, index + 1);
entries.push(state);
index += 1;
},
replaceState(state: unknown, unused?: string, url?: string) {
void unused;
void url;
entries[index] = state;
},
};
return {
history,
events,
get index() {
return index;
},
getCurrentIndex: () => index,
listen(listener: typeof onPopState) {
onPopState = listener;
},
back() {
history.go(-1);
},
forward() {
history.go(1);
},
};
}
function createDirtyBackNavigation() {
const seed = { value: "72.4" };
const draft = { value: "73.1" };
const dismissal = createSeededFormDismissalController({
seed,
value: draft,
});
const historyHarness = createHistoryHarness();
const requestClose = vi.fn((close: () => void) => {
dismissal.requestClose(close);
return dismissal.getState().discardDialogOpen;
});
const historyGuard = createSeededFormHistoryTraversalGuard({
history: historyHarness.history,
getCurrentIndex: historyHarness.getCurrentIndex,
shouldBlock: () => dismissal.getState().isDirty,
requestClose,
});
historyHarness.listen((event) => historyGuard.handlePopState(event));
return {
dismissal,
draft,
historyGuard,
historyHarness,
requestClose,
};
}
it("restores the form route and dirty draft when Back is cancelled", () => {
const { dismissal, draft, historyGuard, historyHarness, requestClose } =
createDirtyBackNavigation();
historyHarness.back();
expect(historyHarness.index).toBe(1);
expect(dismissal.getState().discardDialogOpen).toBe(true);
expect(requestClose).toHaveBeenCalledOnce();
historyGuard.cancelPendingTraversal();
dismissal.cancelDiscard();
expect(historyHarness.index).toBe(1);
expect(draft).toEqual({ value: "73.1" });
expect(dismissal.getState()).toEqual({
isDirty: true,
discardDialogOpen: false,
});
historyHarness.back();
expect(historyHarness.index).toBe(1);
expect(requestClose).toHaveBeenCalledTimes(2);
});
it("does not prompt twice while the same Back dismissal is pending", () => {
const { dismissal, historyHarness, requestClose } =
createDirtyBackNavigation();
historyHarness.back();
historyHarness.back();
expect(historyHarness.index).toBe(1);
expect(historyHarness.events).toHaveLength(4);
expect(requestClose).toHaveBeenCalledOnce();
expect(dismissal.getState().discardDialogOpen).toBe(true);
});
it("allows the original Back traversal after discard is confirmed", () => {
const { dismissal, historyHarness } = createDirtyBackNavigation();
historyHarness.back();
dismissal.confirmDiscard();
expect(historyHarness.index).toBe(0);
expect(dismissal.getState().discardDialogOpen).toBe(false);
expect(
historyHarness.events.at(-1)?.stopImmediatePropagation,
).not.toHaveBeenCalled();
});
it("restores the form route when Forward follows a Back return and edit without the Navigation API", () => {
let dirty = false;
const historyHarness = createHistoryHarness();
const requestClose = vi.fn(() => true);
const historyGuard = createSeededFormHistoryTraversalGuard({
history: historyHarness.history,
shouldBlock: () => dirty,
requestClose,
});
historyHarness.listen((event) => historyGuard.handlePopState(event));
historyHarness.history.replaceState(
{ section: "profile" },
"",
"/settings?section=profile",
);
historyHarness.history.pushState({}, "", "/next");
historyHarness.back();
dirty = true;
historyHarness.forward();
expect(historyHarness.index).toBe(1);
expect(requestClose).toHaveBeenCalledOnce();
});
it("leaves clean-form Back traversal untouched", () => {
const historyHarness = createHistoryHarness();
const requestClose = vi.fn(() => true);
const historyGuard = createSeededFormHistoryTraversalGuard({
history: historyHarness.history,
getCurrentIndex: historyHarness.getCurrentIndex,
shouldBlock: () => false,
requestClose,
});
historyHarness.listen((event) => historyGuard.handlePopState(event));
historyHarness.back();
expect(historyHarness.index).toBe(0);
expect(requestClose).not.toHaveBeenCalled();
expect(
historyHarness.events[0]?.stopImmediatePropagation,
).not.toHaveBeenCalled();
});
});
describe("safeInternalNavigationTarget", () => {
it("keeps only same-origin path, query, and hash state", () => {
expect(
safeInternalNavigationTarget(
"https://healthlog.test/settings/security?from=account#passkeys",
"https://healthlog.test/settings/account",
),
).toBe("/settings/security?from=account#passkeys");
});
it("rejects external and credential-bearing navigation targets", () => {
expect(
safeInternalNavigationTarget(
"https://example.test/settings/security",
"https://healthlog.test/settings/account",
),
).toBeNull();
expect(
safeInternalNavigationTarget(
"https://user:secret@healthlog.test/settings/security",
"https://healthlog.test/settings/account",
),
).toBeNull();
});
});
describe("seeded dismissal migrations", () => {
it.each([
"measurements/measurement-list.tsx",
"mood/mood-list.tsx",
"labs/lab-history-list.tsx",
])(
"routes every %s edit exit through the shared contract",
(relativePath) => {
const source = readFileSync(
new URL(`../../${relativePath}`, import.meta.url),
"utf8",
);
expect(source).toContain("useSeededFormDismissal({");
expect(source).toContain("editDismissal.requestClose(dismissEdit)");
expect(source).toContain("<SeededFormDiscardDialog");
},
);
it("guards profile navigation and refreshes its seed after a successful save", () => {
const source = readFileSync(
new URL("../../settings/account-section/index.tsx", import.meta.url),
"utf8",
);
expect(source).toContain("guardNavigation: true");
expect(source).toContain("setProfileSeed(savedProfile)");
expect(source).toContain("<SeededFormDiscardDialog");
});
});
@@ -0,0 +1,456 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { useTranslations } from "@/lib/i18n/context";
type CloseRequest = () => void;
type ControllerOptions<T> = {
seed: T;
value: T;
blocked?: boolean;
equals?: (seed: T, value: T) => boolean;
onStateChange?: () => void;
};
type ControllerSync<T> = Pick<
ControllerOptions<T>,
"seed" | "value" | "blocked"
>;
export type SeededFormDismissalController<T> = {
sync(options: ControllerSync<T>): void;
getState(): { isDirty: boolean; discardDialogOpen: boolean };
requestClose(close: CloseRequest): void;
confirmDiscard(): void;
cancelDiscard(): void;
};
function seededValuesEqual<T>(seed: T, value: T): boolean {
return JSON.stringify(seed) === JSON.stringify(value);
}
export function createSeededFormDismissalController<T>(
initial: ControllerOptions<T>,
): SeededFormDismissalController<T> {
let seed = initial.seed;
let value = initial.value;
let blocked = initial.blocked ?? false;
let discardDialogOpen = false;
let pendingClose: CloseRequest | null = null;
const equals = initial.equals ?? seededValuesEqual;
const notify = initial.onStateChange ?? (() => undefined);
return {
sync(next) {
seed = next.seed;
value = next.value;
blocked = next.blocked ?? false;
},
getState() {
return {
isDirty: !equals(seed, value),
discardDialogOpen,
};
},
requestClose(close) {
if (blocked) return;
if (equals(seed, value)) {
close();
return;
}
pendingClose = close;
discardDialogOpen = true;
notify();
},
confirmDiscard() {
if (blocked || !discardDialogOpen) return;
const close = pendingClose;
pendingClose = null;
discardDialogOpen = false;
notify();
close?.();
},
cancelDiscard() {
if (!discardDialogOpen) return;
pendingClose = null;
discardDialogOpen = false;
notify();
},
};
}
type HistoryTraversal = {
readonly state?: unknown;
go(delta: number): void;
pushState?(data: unknown, unused: string, url?: string | URL | null): void;
replaceState?(data: unknown, unused: string, url?: string | URL | null): void;
};
type HistoryTraversalGuardOptions = {
history: HistoryTraversal;
getCurrentIndex?: () => number | undefined;
shouldBlock: () => boolean;
requestClose: (close: CloseRequest) => boolean;
};
type InterceptablePopStateEvent = {
stopImmediatePropagation(): void;
};
export type SeededFormHistoryTraversalGuard = {
handlePopState(event: InterceptablePopStateEvent): void;
cancelPendingTraversal(): void;
dispose(): void;
};
const SEEDED_FORM_HISTORY_INDEX = "__healthlogSeededFormHistoryIndex";
function createHistoryEntryIndexTracker(history: HistoryTraversal) {
const getCurrentIndex = () => {
if (
history.state === null ||
typeof history.state !== "object" ||
Array.isArray(history.state)
) {
return undefined;
}
const index = (history.state as Record<string, unknown>)[
SEEDED_FORM_HISTORY_INDEX
];
return typeof index === "number" ? index : undefined;
};
const originalPushState = history.pushState;
const originalReplaceState = history.replaceState;
if (originalPushState === undefined || originalReplaceState === undefined) {
return { getCurrentIndex, dispose: () => undefined };
}
const withIndex = (state: unknown, index: number) => ({
...(state !== null && typeof state === "object" && !Array.isArray(state)
? state
: {}),
[SEEDED_FORM_HISTORY_INDEX]: index,
});
if (getCurrentIndex() === undefined) {
originalReplaceState.call(history, withIndex(history.state, 0), "");
}
const trackedPushState: NonNullable<HistoryTraversal["pushState"]> = (
data,
unused,
url,
) => {
const nextIndex = (getCurrentIndex() ?? 0) + 1;
originalPushState.call(history, withIndex(data, nextIndex), unused, url);
};
const trackedReplaceState: NonNullable<HistoryTraversal["replaceState"]> = (
data,
unused,
url,
) => {
originalReplaceState.call(
history,
withIndex(data, getCurrentIndex() ?? 0),
unused,
url,
);
};
history.pushState = trackedPushState;
history.replaceState = trackedReplaceState;
return {
getCurrentIndex,
dispose() {
if (history.pushState === trackedPushState) {
history.pushState = originalPushState;
}
if (history.replaceState === trackedReplaceState) {
history.replaceState = originalReplaceState;
}
},
};
}
export function createSeededFormHistoryTraversalGuard({
history,
getCurrentIndex,
shouldBlock,
requestClose,
}: HistoryTraversalGuardOptions): SeededFormHistoryTraversalGuard {
const nativeIndex = getCurrentIndex?.();
const indexTracker =
nativeIndex === undefined
? createHistoryEntryIndexTracker(history)
: undefined;
const currentIndex =
indexTracker === undefined
? () => getCurrentIndex?.()
: indexTracker.getCurrentIndex;
const guardedIndex = currentIndex();
let allowNextTraversal = false;
let restoringRoute = false;
let promptOpen = false;
let confirmedWhileRestoring = false;
let confirmedTraversalDelta = -1;
const continueTraversal = () => {
promptOpen = false;
if (restoringRoute) {
confirmedWhileRestoring = true;
return;
}
allowNextTraversal = true;
history.go(confirmedTraversalDelta);
};
return {
handlePopState(event) {
if (allowNextTraversal) {
allowNextTraversal = false;
return;
}
if (restoringRoute) {
event.stopImmediatePropagation();
restoringRoute = false;
if (confirmedWhileRestoring) {
confirmedWhileRestoring = false;
allowNextTraversal = true;
history.go(confirmedTraversalDelta);
}
return;
}
if (!shouldBlock()) return;
event.stopImmediatePropagation();
const traversedIndex = currentIndex();
const restorationDelta =
guardedIndex === undefined ||
traversedIndex === undefined ||
guardedIndex === traversedIndex
? 1
: guardedIndex - traversedIndex;
confirmedTraversalDelta = -restorationDelta;
restoringRoute = true;
history.go(restorationDelta);
if (promptOpen) return;
promptOpen = true;
if (!requestClose(continueTraversal)) {
promptOpen = false;
}
},
cancelPendingTraversal() {
promptOpen = false;
confirmedWhileRestoring = false;
},
dispose() {
indexTracker?.dispose();
},
};
}
export function safeInternalNavigationTarget(
href: string,
currentHref: string,
): string | null {
try {
const current = new URL(currentHref);
const target = new URL(href, current);
if (
target.protocol !== current.protocol ||
target.origin !== current.origin ||
target.username !== "" ||
target.password !== ""
) {
return null;
}
return `${target.pathname}${target.search}${target.hash}`;
} catch {
return null;
}
}
type UseSeededFormDismissalOptions<T> = {
seed: T;
value: T;
blocked?: boolean;
guardNavigation?: boolean;
navigate?: (target: string) => void;
};
export function useSeededFormDismissal<T>({
seed,
value,
blocked = false,
guardNavigation = false,
navigate,
}: UseSeededFormDismissalOptions<T>) {
const [discardDialogOpen, setDiscardDialogOpen] = useState(false);
const pendingCloseRef = useRef<CloseRequest | null>(null);
const historyTraversalGuardRef =
useRef<SeededFormHistoryTraversalGuard | null>(null);
const isDirty = !seededValuesEqual(seed, value);
const requestClose = useCallback(
(close: CloseRequest) => {
if (blocked) return false;
if (!isDirty) {
close();
return true;
}
pendingCloseRef.current = close;
setDiscardDialogOpen(true);
return true;
},
[blocked, isDirty],
);
const confirmDiscard = useCallback(() => {
if (blocked || !discardDialogOpen) return;
const close = pendingCloseRef.current;
pendingCloseRef.current = null;
setDiscardDialogOpen(false);
close?.();
}, [blocked, discardDialogOpen]);
const cancelDiscard = useCallback(() => {
historyTraversalGuardRef.current?.cancelPendingTraversal();
pendingCloseRef.current = null;
setDiscardDialogOpen(false);
}, []);
useEffect(() => {
if (!guardNavigation) return;
const navigation = (
window as Window & {
navigation?: { currentEntry?: { index: number } };
}
).navigation;
const historyTraversalGuard = createSeededFormHistoryTraversalGuard({
history: window.history,
getCurrentIndex: () => navigation?.currentEntry?.index,
shouldBlock: () => isDirty,
requestClose,
});
historyTraversalGuardRef.current = historyTraversalGuard;
const handlePopState = (event: PopStateEvent) => {
historyTraversalGuard.handlePopState(event);
};
window.addEventListener("popstate", handlePopState, true);
return () => {
window.removeEventListener("popstate", handlePopState, true);
historyTraversalGuard.dispose();
if (historyTraversalGuardRef.current === historyTraversalGuard) {
historyTraversalGuardRef.current = null;
}
};
}, [guardNavigation, isDirty, requestClose]);
useEffect(() => {
if (!guardNavigation || !isDirty) return;
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
event.preventDefault();
event.returnValue = "";
};
const handleDocumentClick = (event: MouseEvent) => {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey ||
!(event.target instanceof Element)
) {
return;
}
const anchor = event.target.closest<HTMLAnchorElement>("a[href]");
if (
!anchor ||
anchor.hasAttribute("download") ||
(anchor.target !== "" && anchor.target !== "_self")
) {
return;
}
const target = safeInternalNavigationTarget(
anchor.href,
window.location.href,
);
if (target === null || navigate === undefined) return;
const targetUrl = new URL(target, window.location.origin);
const currentDocument = `${window.location.pathname}${window.location.search}`;
if (`${targetUrl.pathname}${targetUrl.search}` === currentDocument)
return;
event.preventDefault();
requestClose(() => navigate(target));
};
window.addEventListener("beforeunload", handleBeforeUnload);
document.addEventListener("click", handleDocumentClick, true);
return () => {
window.removeEventListener("beforeunload", handleBeforeUnload);
document.removeEventListener("click", handleDocumentClick, true);
};
}, [guardNavigation, isDirty, navigate, requestClose]);
return {
isDirty,
discardDialogOpen,
requestClose,
confirmDiscard,
cancelDiscard,
};
}
export function SeededFormDiscardDialog({
open,
onConfirm,
onCancel,
}: {
open: boolean;
onConfirm: () => void;
onCancel: () => void;
}) {
const { t } = useTranslations();
return (
<AlertDialog open={open} onOpenChange={(next) => !next && onCancel()}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("dashboard.quickEntryDiscard.title")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("dashboard.quickEntryDiscard.description")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={onCancel}>
{t("dashboard.quickEntryDiscard.cancel")}
</AlertDialogCancel>
<AlertDialogAction onClick={onConfirm}>
{t("dashboard.quickEntryDiscard.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -24,6 +24,10 @@ vi.mock("next/dynamic", () => ({
},
}));
vi.mock("next/navigation", () => ({
usePathname: () => "/insights/body-temperature",
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { timezone: "UTC", dateOfBirth: null, gender: null },
@@ -120,6 +124,41 @@ describe("<HealthKitMetricPage> status mount", () => {
);
});
it("exposes the manual capture type on the populated branch", () => {
analyticsMock.mockReturnValue({
data: { summaries: { HEART_RATE_VARIABILITY: { count: 12 } } },
isEmpty: false,
});
metricStatusMock.mockReturnValue({ data: undefined, isLoading: false });
const html = render(
<HealthKitMetricPage
{...baseProps}
emptyStateCtaType={null}
captureType="BODY_TEMPERATURE"
/>,
);
expect(html).toContain('data-slot="metric-add-reading"');
expect(html).toContain(
"/measurements?add=BODY_TEMPERATURE&amp;returnTo=%2Finsights%2Fbody-temperature",
);
});
it("does not expose capture for a populated device-only metric", () => {
analyticsMock.mockReturnValue({
data: { summaries: { HEART_RATE_VARIABILITY: { count: 12 } } },
isEmpty: false,
});
metricStatusMock.mockReturnValue({ data: undefined, isLoading: false });
const html = render(
<HealthKitMetricPage {...baseProps} emptyStateCtaType={null} />,
);
expect(html).not.toContain('data-slot="metric-add-reading"');
});
it("does not mount the assessment card when statusMetric is omitted", () => {
analyticsMock.mockReturnValue({
data: { summaries: { HEART_RATE_VARIABILITY: { count: 12 } } },
@@ -1,9 +1,13 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { SubPageShell } from "../sub-page-shell";
vi.mock("next/navigation", () => ({
usePathname: () => "/insights/weight",
}));
/**
* v1.4.25 W4 — `<SubPageShell>` unit tests.
*
@@ -136,6 +140,36 @@ describe("<SubPageShell>", () => {
expect(html).not.toContain("items-center gap-0.5");
});
it("links a populated metric capture action to a preselected one-shot form", () => {
const html = render(
<SubPageShell
title="Weight"
captureType="WEIGHT"
showAllValuesType="WEIGHT"
>
<span />
</SubPageShell>,
);
const idx = html.indexOf('data-slot="metric-add-reading"');
expect(idx).toBeGreaterThan(-1);
expect(idx).toBeLessThan(html.indexOf("</header>"));
expect(html).toContain('aria-label="Add: Weight"');
expect(html).toContain(
"/measurements?add=WEIGHT&amp;returnTo=%2Finsights%2Fweight",
);
});
it("omits the populated metric capture action without a capture type", () => {
const html = render(
<SubPageShell title="Weight" showAllValuesType="WEIGHT">
<span />
</SubPageShell>,
);
expect(html).not.toContain('data-slot="metric-add-reading"');
});
it("omits the show-all-values control without a type", () => {
const html = render(
<SubPageShell title="BMI">
@@ -28,6 +28,14 @@ function render(node: React.ReactNode) {
);
}
function expectSingleSectionHeading(html: string, title: string) {
const headings = Array.from(
html.matchAll(/<h2(?:\s[^>]*)?>(.*?)<\/h2>/g),
(match) => match[1],
);
expect(headings).toEqual([title]);
}
const FIXTURE: WorkoutDetailPayload = {
id: "w-1",
sportType: "running",
@@ -68,6 +76,7 @@ describe("<WorkoutDetailHeader>", () => {
describe("<WorkoutDetailStats>", () => {
it("renders one tile per available field", () => {
const html = render(<WorkoutDetailStats workout={FIXTURE} />);
expectSingleSectionHeading(html, "Stats");
expect(html).toContain("Duration");
expect(html).toContain("Distance");
expect(html).toContain("Active energy");
@@ -141,6 +150,7 @@ describe("<WorkoutDetailRoute>", () => {
},
};
const html = render(<WorkoutDetailRoute workout={withRoute} />);
expectSingleSectionHeading(html, "Route");
expect(html).toContain('data-slot="workout-detail-route"');
expect(html).toContain("<path");
expect(html).toContain("Export GPX");
@@ -183,6 +193,7 @@ describe("<WorkoutDetailHrSection>", () => {
},
};
const html = render(<WorkoutDetailHrSection workout={withHr} />);
expectSingleSectionHeading(html, "Heart rate");
expect(html).toContain('data-slot="workout-detail-hr"');
expect(html).toContain('data-slot="workout-detail-hr-provenance"');
expect(html).toContain("From your heart-rate data");
@@ -228,6 +239,7 @@ describe("<WorkoutDetailZones>", () => {
},
};
const html = render(<WorkoutDetailZones workout={withZones} />);
expectSingleSectionHeading(html, "Effort zones");
expect(html).toContain('data-slot="workout-detail-zones"');
expect(html).toContain("Effort zones");
expect(html).toContain("Z3");
@@ -248,6 +260,7 @@ describe("<WorkoutDetailSplits>", () => {
],
};
const html = render(<WorkoutDetailSplits workout={withSplits} />);
expectSingleSectionHeading(html, "Splits");
expect(html).toContain('data-slot="workout-detail-splits"');
expect(html).toContain("5:00");
expect(html).toContain("4:48 /km");
@@ -257,6 +270,7 @@ describe("<WorkoutDetailSplits>", () => {
describe("<WorkoutDetailDayLinks>", () => {
it("renders the that-day navigation links", () => {
const html = render(<WorkoutDetailDayLinks workout={FIXTURE} />);
expectSingleSectionHeading(html, "That day");
expect(html).toContain('data-slot="workout-detail-day-links"');
expect(html).toContain('href="/insights/pulse"');
expect(html).toContain('href="/insights/sleep"');
@@ -282,6 +296,7 @@ describe("<WorkoutInsightCard>", () => {
}}
/>,
);
expectSingleSectionHeading(html, "Activity insight");
expect(html).toContain("A steady, aerobic-leaning ride.");
expect(html).toContain('data-slot="workout-detail-insight"');
});
@@ -68,4 +68,34 @@ describe("<WorkoutList>", () => {
expect(html).toContain("freediving");
expect(html).not.toContain("insights.workouts.sport.");
});
it("renders an accessible load-more control and its pending/error states", () => {
const ready = render(
<WorkoutList workouts={[ROW]} hasNextPage onLoadMore={() => {}} />,
);
expect(ready).toContain('data-slot="workout-list-load-more"');
expect(ready).toContain("Load more workouts");
const pending = render(
<WorkoutList
workouts={[ROW]}
hasNextPage
isFetchingNextPage
onLoadMore={() => {}}
/>,
);
expect(pending).toContain('aria-busy="true"');
expect(pending).toContain("Loading more workouts");
const failed = render(
<WorkoutList
workouts={[ROW]}
hasNextPage
isFetchNextPageError
onLoadMore={() => {}}
/>,
);
expect(failed).toContain('role="alert"');
expect(failed).toContain("More workouts could not be loaded");
expect(failed).toContain(">Retry<");
});
});
@@ -0,0 +1,36 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { I18nProvider } from "@/lib/i18n/context";
import {
ConversationRename,
getConversationRenameKeyAction,
} from "../conversation-rename";
function renderRename() {
const client = new QueryClient();
return renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">
<ConversationRename id="c1" title="Morning check-in" />
</I18nProvider>
</QueryClientProvider>,
);
}
describe("ConversationRename", () => {
it("renders a keyboard-focusable rename affordance with a localized label", () => {
const html = renderRename();
expect(html).toContain('data-slot="coach-conversation-rename"');
expect(html).toContain('aria-label="Rename conversation"');
expect(html).toContain('type="button"');
});
it("maps Enter to save and Escape to cancel without hijacking other keys", () => {
expect(getConversationRenameKeyAction("Enter")).toBe("save");
expect(getConversationRenameKeyAction("Escape")).toBe("cancel");
expect(getConversationRenameKeyAction("a")).toBeNull();
});
});
@@ -176,6 +176,17 @@ describe("<HistoryRail>", () => {
expect(buttons.length).toBe(2);
});
it("renders an accessible rename button for each conversation row", () => {
const client = makeClientWithConversations(samplePage);
const html = render(
<HistoryRail activeId={null} onSelect={() => {}} />,
client,
);
const buttons = html.match(/data-slot="coach-conversation-rename"/g) ?? [];
expect(buttons.length).toBe(2);
expect(html).toContain('aria-label="Rename conversation"');
});
// v1.30.2 (QoL H1) — infinite history: reachability beyond the first page.
it("renders every conversation across multiple cached pages (not just page one)", () => {
const client = makeClientWithPages([
@@ -10,6 +10,14 @@ function render(node: React.ReactNode, locale: "en" | "de" = "en") {
);
}
function classesForSlot(html: string, slot: string): string[] {
const elements =
html.match(new RegExp(`<[^>]+data-slot="${slot}"[^>]*>`, "g")) ?? [];
return elements.map(
(element) => element.match(/\bclass="([^"]*)"/)?.[1] ?? "",
);
}
describe("<SourceChips>", () => {
it("renders nothing when provenance is null", () => {
const html = render(<SourceChips provenance={null} />);
@@ -56,6 +64,29 @@ describe("<SourceChips>", () => {
expect(html).toContain("n=7");
});
it("uses full semantic text color on source labels", () => {
const html = render(
<SourceChips
provenance={{
windows: ["last7days"],
metrics: ["bp"],
counts: { bp: 7 },
}}
/>,
);
const classNames = [
...classesForSlot(html, "coach-source-chip"),
...classesForSlot(html, "coach-source-window"),
...classesForSlot(html, "coach-source-count"),
];
expect(classNames).toHaveLength(3);
for (const className of classNames) {
expect(className.split(/\s+/)).toContain("text-info");
expect(className).not.toMatch(/\b(?:opacity-\d+|text-\S+\/\d+)\b/);
}
});
it("does not render n=0 chips for zero counts", () => {
const html = render(
<SourceChips
@@ -0,0 +1,152 @@
import { QueryClient } from "@tanstack/react-query";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type {
CoachConversationDTO,
CoachConversationDetailDTO,
CoachConversationsPage,
} from "@/lib/ai/coach/types";
import { queryKeys } from "@/lib/query-keys";
import {
applyOptimisticCoachConversationRename,
invalidateCoachConversationRename,
patchCoachConversationTitle,
restoreCoachConversationRename,
} from "../use-coach";
function conversation(id: string, title: string): CoachConversationDTO {
return {
id,
title,
createdAt: "2026-07-20T10:00:00.000Z",
updatedAt: "2026-07-20T10:00:00.000Z",
messageCount: 2,
fenced: false,
};
}
function page(
...conversations: CoachConversationDTO[]
): CoachConversationsPage {
return { conversations, nextCursor: null };
}
describe("Coach conversation rename cache lifecycle", () => {
let client: QueryClient;
const originalFetch = global.fetch;
beforeEach(() => {
client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
global.fetch = originalFetch;
});
it("optimistically updates the head list, every history variant, and detail cache", async () => {
const target = conversation("c1", "Before");
const other = conversation("c2", "Other");
client.setQueryData(queryKeys.coachConversations(), page(target, other));
client.setQueryData(queryKeys.coachConversationHistory(""), {
pages: [page(target), page(other)],
pageParams: [null, "cursor-1"],
});
client.setQueryData(queryKeys.coachConversationHistory("before"), {
pages: [page(target)],
pageParams: [null],
});
client.setQueryData<CoachConversationDetailDTO>(
queryKeys.coachConversation("c1"),
{ ...target, attachmentCount: 0, messages: [] },
);
await applyOptimisticCoachConversationRename(client, {
id: "c1",
title: "After",
});
expect(
client.getQueryData<CoachConversationsPage>(
queryKeys.coachConversations(),
)?.conversations[0].title,
).toBe("After");
for (const search of ["", "before"]) {
const data = client.getQueryData<{
pages: CoachConversationsPage[];
}>(queryKeys.coachConversationHistory(search));
expect(data?.pages[0].conversations[0].title).toBe("After");
}
expect(
client.getQueryData<CoachConversationDetailDTO>(
queryKeys.coachConversation("c1"),
)?.title,
).toBe("After");
});
it("restores the exact cache snapshots after a failed request", async () => {
const target = conversation("c1", "Before");
const head = page(target);
const history = { pages: [page(target)], pageParams: [null] };
const detail: CoachConversationDetailDTO = {
...target,
attachmentCount: 0,
messages: [],
};
client.setQueryData(queryKeys.coachConversations(), head);
client.setQueryData(queryKeys.coachConversationHistory(""), history);
client.setQueryData(queryKeys.coachConversation("c1"), detail);
const snapshot = await applyOptimisticCoachConversationRename(client, {
id: "c1",
title: "After",
});
restoreCoachConversationRename(client, snapshot);
expect(client.getQueryData(queryKeys.coachConversations())).toStrictEqual(
head,
);
expect(
client.getQueryData(queryKeys.coachConversationHistory("")),
).toStrictEqual(history);
expect(
client.getQueryData(queryKeys.coachConversation("c1")),
).toStrictEqual(detail);
});
it("persists the trimmed title with PATCH and returns the server result", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
data: { id: "c1", title: "Persisted" },
error: null,
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
global.fetch = fetchMock;
await expect(
patchCoachConversationTitle({ id: "c1", title: " Persisted " }),
).resolves.toEqual({ id: "c1", title: "Persisted" });
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
"/api/insights/chat/c1",
expect.objectContaining({
method: "PATCH",
body: JSON.stringify({ title: "Persisted" }),
}),
);
});
it("invalidates the list prefix and renamed detail on settle", async () => {
const invalidate = vi.spyOn(client, "invalidateQueries");
await invalidateCoachConversationRename(client, "c1");
expect(invalidate).toHaveBeenCalledWith({
queryKey: queryKeys.coachConversations(),
});
expect(invalidate).toHaveBeenCalledWith({
queryKey: queryKeys.coachConversation("c1"),
});
});
});
@@ -557,7 +557,7 @@ function ChatBubbleImpl({
<div
aria-hidden="true"
data-slot="coach-bubble-user-avatar"
className="text-muted-foreground bg-muted/60 mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full text-xs font-semibold"
className="text-foreground bg-muted/60 mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full text-xs font-semibold"
>
{initials ?? <User className="size-3.5" />}
</div>
@@ -1090,6 +1090,7 @@ export function CoachConversation({
className={cn("flex min-h-0 flex-1 flex-col", className)}
>
{docScopeBanner}
{!heroActive ? <h1 className="sr-only">{title}</h1> : null}
{/* v1.21.4 (A) — the page-toolbar gear was removed; Settings now lives
in the composer's `+` actions menu alongside New chat and
Conversations, keeping the page chrome to the composer alone. */}
@@ -12,7 +12,10 @@ import {
SheetDescription,
SheetTitle,
} from "@/components/ui/sheet";
import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context";
import type {
CoachCloseIntent,
CoachLaunchScope,
} from "@/lib/insights/coach-launch-context";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { useIsMobile } from "@/hooks/use-is-mobile";
@@ -73,7 +76,7 @@ export function coachMaximizeHref(
*/
export interface CoachDrawerProps {
open: boolean;
onOpenChange: (next: boolean) => void;
onOpenChange: (next: boolean, closeIntent?: CoachCloseIntent) => void;
/** Optional pre-fill for the input box (suggested-prompt chip click). */
prefill?: string | null;
/**
@@ -166,7 +169,7 @@ export function CoachDrawer({
);
// Close the drawer (aborts any in-flight stream via `handleOpenChange`)
// then route to the dedicated page.
onOpenChange(false);
onOpenChange(false, "navigate");
resetRef.current?.();
router.push(href);
}, [onOpenChange, router, documentId, workoutId]);
@@ -177,7 +180,7 @@ export function CoachDrawer({
// Coach page. The former `?view=conversations` in-page slide-in drawer is
// gone; selecting a row there routes back to `/coach?c=<id>`.
const handleOpenConversations = useCallback(() => {
onOpenChange(false);
onOpenChange(false, "navigate");
resetRef.current?.();
router.push("/coach/conversations");
}, [onOpenChange, router]);
@@ -0,0 +1,164 @@
"use client";
import { useRef, useState } from "react";
import type { FormEvent, KeyboardEvent } from "react";
import { Check, Loader2, Pencil, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { COACH_CONVERSATION_TITLE_MAX } from "@/lib/ai/coach/types";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { useRenameCoachConversation } from "./use-coach";
export type ConversationRenameKeyAction = "save" | "cancel";
export function getConversationRenameKeyAction(
key: string,
): ConversationRenameKeyAction | null {
if (key === "Enter") return "save";
if (key === "Escape") return "cancel";
return null;
}
export interface ConversationRenameProps {
id: string;
title: string;
compact?: boolean;
}
export function ConversationRename({
id,
title,
compact = false,
}: ConversationRenameProps) {
const { t } = useTranslations();
const rename = useRenameCoachConversation();
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(title);
const submittingRef = useRef(false);
function cancel() {
if (rename.isPending) return;
setDraft(title);
setEditing(false);
}
async function save() {
const nextTitle = draft.trim();
if (
submittingRef.current ||
rename.isPending ||
nextTitle.length === 0 ||
nextTitle.length > COACH_CONVERSATION_TITLE_MAX
) {
return;
}
if (nextTitle === title) {
setDraft(title);
setEditing(false);
return;
}
submittingRef.current = true;
try {
await rename.mutateAsync({ id, title: nextTitle });
setEditing(false);
} catch {
// The shared mutation restores every cache and surfaces the localized
// failure. Keep the user's draft in the input so retrying loses nothing.
} finally {
submittingRef.current = false;
}
}
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
void save();
}
function handleKeyDown(event: KeyboardEvent<HTMLInputElement>) {
const action = getConversationRenameKeyAction(event.key);
if (!action) return;
event.preventDefault();
if (action === "cancel") cancel();
else void save();
}
if (!editing) {
return (
<Button
type="button"
variant="ghost"
size={compact ? "icon" : "icon-lg"}
onClick={() => {
setDraft(title);
setEditing(true);
}}
aria-label={t("insights.coach.rename.action")}
data-slot="coach-conversation-rename"
className={cn("shrink-0", compact && "size-11")}
>
<Pencil className="size-4" aria-hidden="true" />
</Button>
);
}
const invalid =
draft.trim().length === 0 ||
draft.trim().length > COACH_CONVERSATION_TITLE_MAX;
return (
<form
onSubmit={handleSubmit}
data-slot="coach-conversation-rename-form"
className={cn(
"bg-background absolute inset-y-1 left-1 z-10 flex min-w-0 items-center gap-1",
compact ? "right-14" : "right-16",
)}
>
<Input
autoFocus
value={draft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={handleKeyDown}
disabled={rename.isPending}
maxLength={COACH_CONVERSATION_TITLE_MAX}
aria-label={t("insights.coach.rename.input")}
data-slot="coach-conversation-rename-input"
className="h-10 min-w-0 flex-1"
/>
<Button
type="submit"
variant="ghost"
size="icon"
disabled={rename.isPending || invalid}
aria-label={t("insights.coach.rename.save")}
data-slot="coach-conversation-rename-save"
className="size-10 shrink-0"
>
{rename.isPending ? (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
) : (
<Check className="size-4" aria-hidden="true" />
)}
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onClick={cancel}
disabled={rename.isPending}
aria-label={t("insights.coach.rename.cancel")}
data-slot="coach-conversation-rename-cancel"
className="size-10 shrink-0"
>
<X className="size-4" aria-hidden="true" />
</Button>
</form>
);
}
@@ -20,6 +20,7 @@ import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useLoadMoreSentinel } from "@/hooks/use-load-more-sentinel";
import { COACH_SCROLLBAR } from "./message-thread";
import { ConversationRename } from "./conversation-rename";
import {
useCoachConversationHistory,
useDeleteCoachConversationWithUndo,
@@ -208,7 +209,7 @@ export function HistoryRail({
data-slot="coach-history-item"
data-active={isActive ? "true" : undefined}
className={cn(
"group flex items-center gap-1.5 rounded-lg px-2.5 py-2",
"group relative flex items-center gap-1.5 rounded-lg px-2.5 py-2",
"text-sm transition-colors",
isActive
? "bg-primary/15 text-foreground"
@@ -245,6 +246,7 @@ export function HistoryRail({
{formatRelativeTime(c.updatedAt, t, locale)}
</span>
</button>
<ConversationRename id={c.id} title={c.title} compact />
{/* v1.4.27 R3d MB2 — drop the hover-only `opacity-0`
reveal: touch surfaces have no hover, so the delete
affordance was invisible on mobile. The button
@@ -86,7 +86,7 @@ export function SourceChips({ provenance, className }: SourceChipsProps) {
data-slot="coach-source-chip"
data-metric={chip.metric}
className={cn(
"border-info/25 text-info/90",
"border-info/25 text-info",
"inline-flex items-center gap-1 rounded-full border bg-transparent",
"px-2 py-0.5 text-xs leading-none",
)}
@@ -94,10 +94,14 @@ export function SourceChips({ provenance, className }: SourceChipsProps) {
<Link2 className="h-2.5 w-2.5" aria-hidden="true" />
<span className="font-medium">{chip.metricLabel}</span>
{chip.windowLabel && (
<span className="opacity-75">· {chip.windowLabel}</span>
<span data-slot="coach-source-window" className="text-info">
· {chip.windowLabel}
</span>
)}
{typeof chip.count === "number" && chip.count > 0 && (
<span className="opacity-60">· n={chip.count}</span>
<span data-slot="coach-source-count" className="text-info">
· n={chip.count}
</span>
)}
</span>
))}
@@ -7,6 +7,8 @@ import {
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import type { QueryClient, QueryKey } from "@tanstack/react-query";
import { toast } from "sonner";
import type {
CoachConversationAttachmentDTO,
@@ -20,7 +22,14 @@ import type {
CoachUsage,
} from "@/lib/ai/coach/types";
import type { CoachSuggestedAction } from "@/lib/ai/coach/suggest-action";
import { apiDelete, apiFetchRaw, apiGet, apiPost } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import {
apiDelete,
apiFetchRaw,
apiGet,
apiPatch,
apiPost,
} from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
/**
@@ -45,6 +54,107 @@ const QUERY_KEYS = {
one: (id: string) => queryKeys.coachConversation(id),
};
export interface RenameCoachConversationInput {
id: string;
title: string;
}
export type CoachConversationRenameSnapshot = Array<
readonly [QueryKey, unknown]
>;
function renameConversationInCache(
data: unknown,
input: RenameCoachConversationInput,
): unknown {
if (!data || typeof data !== "object") return data;
if ("pages" in data && Array.isArray(data.pages)) {
let changed = false;
const pages = data.pages.map((page) => {
const renamed = renameConversationInCache(page, input);
changed ||= renamed !== page;
return renamed;
});
return changed ? { ...data, pages } : data;
}
if ("conversations" in data && Array.isArray(data.conversations)) {
let changed = false;
const conversations = data.conversations.map((conversation) => {
if (
conversation &&
typeof conversation === "object" &&
"id" in conversation &&
conversation.id === input.id
) {
changed = true;
return { ...conversation, title: input.title };
}
return conversation;
});
return changed ? { ...data, conversations } : data;
}
if ("id" in data && data.id === input.id && "title" in data) {
return { ...data, title: input.title };
}
return data;
}
/**
* Capture and update every currently materialised conversation cache. Search
* variants live below the list prefix, while the open thread has its own key.
*/
export async function applyOptimisticCoachConversationRename(
queryClient: QueryClient,
input: RenameCoachConversationInput,
): Promise<CoachConversationRenameSnapshot> {
const detailKey = queryKeys.coachConversation(input.id);
await Promise.all([
queryClient.cancelQueries({ queryKey: QUERY_KEYS.list() }),
queryClient.cancelQueries({ queryKey: detailKey }),
]);
const snapshot: CoachConversationRenameSnapshot = [
...queryClient.getQueriesData({ queryKey: QUERY_KEYS.list() }),
...queryClient.getQueriesData({ queryKey: detailKey, exact: true }),
];
for (const [key, previous] of snapshot) {
queryClient.setQueryData(key, renameConversationInCache(previous, input));
}
return snapshot;
}
export function restoreCoachConversationRename(
queryClient: QueryClient,
snapshot: CoachConversationRenameSnapshot,
): void {
for (const [key, previous] of snapshot) {
queryClient.setQueryData(key, previous);
}
}
export async function invalidateCoachConversationRename(
queryClient: QueryClient,
id: string,
): Promise<void> {
await Promise.all([
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.list() }),
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.one(id) }),
]);
}
export async function patchCoachConversationTitle(
input: RenameCoachConversationInput,
): Promise<RenameCoachConversationInput> {
const title = input.title.trim();
return apiPatch<RenameCoachConversationInput>(
`/api/insights/chat/${encodeURIComponent(input.id)}`,
{ title },
);
}
/**
* The single (first) page of conversations. Used ONLY for the
* auto-open-most-recent-thread behaviour on mount
@@ -287,6 +397,32 @@ export function useDeleteCoachConversationWithUndo() {
return { pendingDeleteIds, requestDelete, undoDelete };
}
/**
* Shared rename mutation for every Coach history surface. It snapshots each
* materialised list/search/detail cache before the optimistic write and
* restores those exact data references if persistence fails.
*/
export function useRenameCoachConversation() {
const queryClient = useQueryClient();
const { t } = useTranslations();
return useMutation({
mutationFn: patchCoachConversationTitle,
onMutate: (input: RenameCoachConversationInput) =>
applyOptimisticCoachConversationRename(queryClient, {
...input,
title: input.title.trim(),
}),
onError: (_error, _input, snapshot) => {
if (snapshot) restoreCoachConversationRename(queryClient, snapshot);
toast.error(t("insights.coach.rename.error"));
},
onSettled: (_data, _error, input) => {
void invalidateCoachConversationRename(queryClient, input.id);
},
});
}
/**
* v1.29.x (S7) — attach a stored document to an EXISTING fenced (or about-to-be-
* fenced) conversation. On success invalidates the conversation detail + rail so
@@ -34,11 +34,10 @@ import { SubPageShell } from "@/components/insights/sub-page-shell";
* envelope:
*
* 1. `<SubPageShell>` with a localised title + description.
* 2. Empty-state CTA when the user has no observations for the
* 2. Optional empty-state CTA when the user has no observations for the
* metric — points at `/measurements?add=<TYPE>` so the existing
* quick-entry dialog can light up. The CTA target is optional;
* Apple-Health-only metrics (active energy, body temperature)
* pass `null` to render an onboarding hint without a dead link.
* quick-entry dialog can light up. Device-led empty states may omit it
* even when the populated page offers manual contextual capture.
* 3. `<HealthChartDynamic>` mounted on the canonical
* `ChartOverlayKey` so the chart-cog popover persists per metric.
* 4. A header-height Coach launch icon (`coachLaunch` on the shell) —
@@ -96,11 +95,16 @@ export interface HealthKitMetricPageProps {
/** Optional value bands (Apple-Health-style target zone shading). */
valueBands?: ComponentProps<typeof HealthChartDynamic>["valueBands"];
/**
* Optional empty-state CTA target. `null` renders the empty state
* without a primary action (Apple-Health-only metrics that have no
* manual entry form). String values land in `/measurements?add=<x>`.
* Optional empty-state CTA target. `null` renders the empty state without a
* primary action. String values land in `/measurements?add=<x>`.
*/
emptyStateCtaType?: string | null;
/**
* Manual capture type for the populated-page header action. When omitted,
* the empty-state CTA type is reused; pass it explicitly when a metric is
* manually supported but its empty state deliberately remains device-led.
*/
captureType?: string | null;
/** Icon node mounted in the empty-state card. */
emptyStateIcon: ReactNode;
/**
@@ -197,6 +201,7 @@ export function HealthKitMetricPage({
yAxisUnit,
valueBands,
emptyStateCtaType,
captureType,
emptyStateIcon,
coachPrefill,
valueScale,
@@ -372,6 +377,10 @@ export function HealthKitMetricPage({
/>
}
coachLaunch
captureType={
(captureType === undefined ? emptyStateCtaType : captureType) ??
undefined
}
showAllValuesType={effectiveType}
>
<HealthChartDynamic
@@ -18,6 +18,7 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { useMounted } from "@/hooks/use-mounted";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
@@ -648,6 +649,7 @@ function InsightsTabStripImpl({
}: InsightsTabStripProps) {
const { t } = useTranslations();
const pathname = usePathname();
const mounted = useMounted();
// v1.4.31 — memoise the pill list so the strip's main render
// path doesn't rebuild the array on every parent re-render. The
// strip is wrapped in `React.memo` (see export below); the memo
@@ -765,6 +767,7 @@ function InsightsTabStripImpl({
<nav
data-slot="insights-tab-strip"
data-tour-id="insights-hero"
data-hydrated={mounted ? "true" : undefined}
aria-label={t("insights.navAriaLabel")}
// v1.4.28 FB-D3 — `touch-action: pan-y` lets vertical swipes that
// begin on the sticky strip scroll the page through it. The
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useRef, useState, type FormEvent } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
@@ -15,11 +15,9 @@ import { queryKeys, refetchInactiveDailyReads } from "@/lib/query-keys";
/**
* v1.29 — manual water quick-add sheet, opened from the hydration card.
*
* Chips (+200/+300/+500 mL) fire immediately (mode "add") and keep the
* sheet open so a run of taps composes one round number; the sheet stays
* a lightweight stepper, not a form the user has to re-open per tap. The
* custom-amount field and the "edit today's total" disclosure both close
* on submit — the two are one-shot actions.
* Chips (+200/+300/+500 mL) and custom amounts are one-shot writes:
* the sheet closes and clears only after the API confirms success. A
* rejected write leaves the sheet and input untouched so the user can retry.
*
* Every write hits `POST /api/nutrients/water`, which owns ONLY the
* `source="MANUAL"` row (migration 0249) — never the Apple-synced row.
@@ -30,20 +28,27 @@ const QUICK_CHIPS_ML = [200, 300, 500] as const;
interface WaterQuickAddSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Today's combined (all-source) total, for the "edit total" pre-fill. */
todayTotalMl: number;
/** Called after a confirmed write; bypasses dirty-dismiss guards. */
onSuccess?: () => void;
/** Today's combined total. Omit outside the hydration detail surface. */
todayTotalMl?: number;
}
export function WaterQuickAddSheet({
open,
onOpenChange,
onSuccess,
todayTotalMl,
}: WaterQuickAddSheetProps) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [customAmount, setCustomAmount] = useState("");
const [editMode, setEditMode] = useState(false);
const [editAmount, setEditAmount] = useState(() => String(todayTotalMl));
const [editAmount, setEditAmount] = useState(() =>
todayTotalMl == null ? "" : String(todayTotalMl),
);
const writePendingRef = useRef(false);
const closeAfterSuccess = onSuccess ?? (() => onOpenChange(false));
// v1.29.x — the Today digest joins the invalidation. The write route
// already hard-evicts the server snapshot bucket
@@ -54,7 +59,7 @@ export function WaterQuickAddSheet({
// `refetchInactiveDailyReads` forces the inactive refetch, mirroring the
// measurement / mood / medication fix.
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: ["nutrients"] });
void queryClient.invalidateQueries({ queryKey: queryKeys.nutrientsRoot() });
void queryClient.invalidateQueries({
queryKey: queryKeys.dashboardSnapshot(),
});
@@ -75,23 +80,52 @@ export function WaterQuickAddSheet({
onSuccess: () => {
invalidate();
setEditMode(false);
onOpenChange(false);
},
onError: () => toast.error(t("nutrients.hydration.quickAddError")),
});
const handleCustomAdd = () => {
const amount = Number(customAmount);
if (!Number.isFinite(amount) || amount <= 0) return;
addMutation.mutate(amount);
setCustomAmount("");
onOpenChange(false);
const addWater = async (amount: number) => {
if (writePendingRef.current) return;
writePendingRef.current = true;
try {
await addMutation.mutateAsync(amount);
toast.success(
t("nutrients.hydration.quickAddSuccess", { amount: String(amount) }),
);
setCustomAmount("");
closeAfterSuccess();
} catch {
// The mutation owns the translated error toast. Preserve all input.
} finally {
writePendingRef.current = false;
}
};
const handleEditSave = () => {
const handleCustomAdd = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const amount = Number(customAmount);
if (!Number.isFinite(amount) || amount <= 0) return;
void addWater(amount);
};
const handleEditSave = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const amount = Number(editAmount);
if (!Number.isFinite(amount) || amount < 0) return;
setMutation.mutate(amount);
if (writePendingRef.current || !Number.isFinite(amount) || amount < 0) {
return;
}
writePendingRef.current = true;
try {
await setMutation.mutateAsync(amount);
toast.success(
t("nutrients.hydration.quickAddSuccess", { amount: String(amount) }),
);
closeAfterSuccess();
} catch {
// The mutation owns the translated error toast. Preserve all input.
} finally {
writePendingRef.current = false;
}
};
return (
@@ -107,24 +141,23 @@ export function WaterQuickAddSheet({
type="button"
variant="outline"
size="sm"
disabled={addMutation.isPending}
onClick={() => {
addMutation.mutate(amountMl);
toast.success(`+${amountMl} mL`);
}}
className="min-h-11 sm:min-h-8"
disabled={addMutation.isPending || setMutation.isPending}
onClick={() => void addWater(amountMl)}
>
+{amountMl} mL
</Button>
))}
</div>
<div className="flex items-end gap-2">
<form className="flex items-end gap-2" onSubmit={handleCustomAdd}>
<div className="flex-1 space-y-1.5">
<Label htmlFor="water-custom-amount">
{t("nutrients.hydration.quickAddCustomLabel")}
</Label>
<Input
id="water-custom-amount"
name="amountMl"
type="number"
inputMode="numeric"
min={1}
@@ -135,22 +168,28 @@ export function WaterQuickAddSheet({
/>
</div>
<Button
type="button"
onClick={handleCustomAdd}
disabled={addMutation.isPending || !customAmount}
type="submit"
className="min-h-11 sm:min-h-9"
disabled={
addMutation.isPending || setMutation.isPending || !customAmount
}
>
{t("nutrients.hydration.quickAddSubmit")}
</Button>
</div>
</form>
{editMode ? (
<div className="border-border/70 space-y-1.5 border-t pt-4">
{todayTotalMl != null && editMode ? (
<form
className="border-border/70 space-y-1.5 border-t pt-4"
onSubmit={handleEditSave}
>
<Label htmlFor="water-edit-total">
{t("nutrients.hydration.editTotal")}
</Label>
<div className="flex items-end gap-2">
<Input
id="water-edit-total"
name="totalMl"
type="number"
inputMode="numeric"
min={0}
@@ -160,20 +199,20 @@ export function WaterQuickAddSheet({
onChange={(e) => setEditAmount(e.target.value)}
/>
<Button
type="button"
onClick={handleEditSave}
disabled={setMutation.isPending}
type="submit"
className="min-h-11 sm:min-h-9"
disabled={addMutation.isPending || setMutation.isPending}
>
{t("nutrients.hydration.quickAddSubmit")}
</Button>
</div>
</div>
) : (
</form>
) : todayTotalMl != null ? (
<Button
type="button"
variant="ghost"
size="sm"
className="text-muted-foreground self-start"
className="text-muted-foreground min-h-11 self-start sm:min-h-8"
onClick={() => {
setEditAmount(String(todayTotalMl));
setEditMode(true);
@@ -181,7 +220,7 @@ export function WaterQuickAddSheet({
>
{t("nutrients.hydration.editTotal")}
</Button>
)}
) : null}
</ResponsiveSheet>
);
}
+41 -1
View File
@@ -3,7 +3,7 @@
import { useEffect, useRef, type ReactNode } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { ListOrdered } from "lucide-react";
import { ListOrdered, Plus } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -13,8 +13,13 @@ import { metricScopeFromExplainer } from "@/components/insights/coach-metric-sco
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
import { useScrollResetOnRoute } from "@/hooks/use-scroll-reset-on-route";
import { useTranslations } from "@/lib/i18n/context";
import { SUB_PAGE_SLUGS } from "@/lib/insights/sub-page-metric";
import { cn } from "@/lib/utils";
const INSIGHT_METRIC_RETURN_PATHS: Readonly<Record<string, true>> =
Object.fromEntries(
SUB_PAGE_SLUGS.map((slug) => [`/insights/${slug}`, true] as const),
);
/**
* v1.4.25 W4 — common visual frame for each metric sub-page.
*
@@ -126,6 +131,11 @@ export interface SubPageShellProps {
* second header affordance: the FAB stays the single entry point.
*/
coachLaunch?: boolean;
/**
* Canonical measurement-form type for the populated page's contextual
* capture action. Omit it for device-only or derived metrics.
*/
captureType?: string;
/**
* v1.8.5 W4b — a "show all readings" entry linking to the dedicated
* `/insights/values/<type>` subpage. Sub-pages pass the metric's
@@ -153,6 +163,7 @@ export function SubPageShell({
// stands) but now gates whether the page registers its metric as the
// Coach launch context's ambient scope, so the FAB opens contextual.
coachLaunch = false,
captureType,
showAllValuesType,
children,
}: SubPageShellProps) {
@@ -163,6 +174,15 @@ export function SubPageShell({
// where the user drilled in from (e.g. `weight → show all values → back to
// weight`) rather than always returning to the Insights overview.
const pathname = usePathname();
const captureReturnTo =
pathname && INSIGHT_METRIC_RETURN_PATHS[pathname] ? pathname : null;
const captureHref =
captureType && captureReturnTo
? `/measurements?add=${encodeURIComponent(
captureType,
)}&returnTo=${encodeURIComponent(captureReturnTo)}`
: null;
const captureLabel = `${t("measurements.addMeasurement")}: ${title}`;
// a11y: focus the heading on mount so a tab-strip navigation actually
// moves screen-reader focus into the sub-page body. v1.4.27 MB7 /
// CF-35 made the focus call opt-in via `focusOnMount` — the
@@ -283,6 +303,26 @@ 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">
{captureHref ? (
<Button
asChild
variant="ghost"
size="icon"
data-slot="metric-add-reading"
className={cn(
"text-muted-foreground hover:text-foreground relative size-10",
"before:absolute before:-inset-1.5 before:content-['']",
)}
>
<Link
href={captureHref}
aria-label={captureLabel}
title={captureLabel}
>
<Plus className="size-4" aria-hidden="true" />
</Link>
</Button>
) : null}
{/* v1.16.8 — "show all readings" rides the header cluster
as an icon button, LEFT of the target-adjust gear (it
used to be a full-width outline button at the page foot).
@@ -53,6 +53,7 @@ export function WorkoutDetailDayLinks({ workout }: WorkoutDetailDayLinksProps) {
<TileHeader
icon={CalendarDays}
title={t("insights.workouts.detail.thatDayTitle")}
titleAs="h2"
/>
</CardHeader>
<CardContent>
@@ -53,6 +53,7 @@ export function WorkoutDetailHrSection({
<TileHeader
icon={HeartPulse}
title={t("insights.workouts.detail.hrChartTitle")}
titleAs="h2"
/>
{series.source === "pulse_window" ? (
<p
@@ -44,6 +44,7 @@ export function WorkoutInsightCard({
<TileHeader
icon={Sparkles}
title={t("insights.workouts.detail.insightTitle")}
titleAs="h2"
/>
</CardHeader>
<CardContent>
@@ -102,6 +102,7 @@ export function WorkoutDetailRoute({ workout }: WorkoutDetailRouteProps) {
<TileHeader
icon={MapIcon}
title={t("insights.workouts.detail.routeTitle")}
titleAs="h2"
right={
<Button
type="button"
@@ -38,6 +38,7 @@ export function WorkoutDetailSplits({ workout }: WorkoutDetailSplitsProps) {
<TileHeader
icon={Flag}
title={t("insights.workouts.detail.splitsTitle")}
titleAs="h2"
/>
</CardHeader>
<CardContent>
@@ -10,7 +10,7 @@ import {
Timer,
} from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
@@ -157,9 +157,12 @@ export function WorkoutDetailStats({ workout }: WorkoutDetailStatsProps) {
return (
<Card data-slot="workout-detail-stats">
<CardHeader>
<CardTitle className="text-base">
<h2
data-slot="card-title"
className="text-base leading-none font-semibold"
>
{t("insights.workouts.detail.statsTitle")}
</CardTitle>
</h2>
</CardHeader>
<CardContent className="space-y-3">
<div
@@ -46,6 +46,7 @@ export function WorkoutDetailZones({ workout }: WorkoutDetailZonesProps) {
<TileHeader
icon={Gauge}
title={t("insights.workouts.detail.zonesTitle")}
titleAs="h2"
/>
<p className="text-muted-foreground text-xs">
{zones.model === "whoop"
+55 -2
View File
@@ -1,7 +1,7 @@
"use client";
import Link from "next/link";
import { Map as MapIcon, HeartPulse } from "lucide-react";
import { HeartPulse, Loader2, Map as MapIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { getNumberFormat, getDateTimeFormat } from "@/lib/intl/formatter-cache";
@@ -12,6 +12,7 @@ import {
} from "@/lib/format-locale";
import { iconForSport } from "@/lib/workouts/sport-icons";
import type { WorkoutListEntry } from "@/hooks/use-workouts";
import { Button } from "@/components/ui/button";
/**
* v1.4.32 — workout list row primitive.
@@ -69,9 +70,20 @@ function formatDate(
export interface WorkoutListProps {
workouts: WorkoutListEntry[];
className?: string;
hasNextPage?: boolean;
isFetchingNextPage?: boolean;
isFetchNextPageError?: boolean;
onLoadMore?: () => void;
}
export function WorkoutList({ workouts, className }: WorkoutListProps) {
export function WorkoutList({
workouts,
className,
hasNextPage = false,
isFetchingNextPage = false,
isFetchNextPageError = false,
onLoadMore,
}: WorkoutListProps) {
const { t, locale } = useTranslations();
const timeFormat = useTimeFormatPreference();
@@ -152,6 +164,47 @@ export function WorkoutList({ workouts, className }: WorkoutListProps) {
</li>
);
})}
{hasNextPage ? (
<li
data-slot="workout-list-load-more"
className="flex flex-col items-center gap-2 p-3"
>
{isFetchNextPageError ? (
<p
id="workout-list-load-more-error"
role="alert"
className="text-destructive text-center text-sm"
>
{t("insights.workouts.loadMoreError")}
</p>
) : null}
<Button
type="button"
variant="outline"
className="min-h-11 min-w-44"
disabled={isFetchingNextPage}
aria-busy={isFetchingNextPage || undefined}
aria-describedby={
isFetchNextPageError ? "workout-list-load-more-error" : undefined
}
onClick={onLoadMore}
>
{isFetchingNextPage ? (
<>
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
{t("insights.workouts.loadingMore")}
</>
) : isFetchNextPageError ? (
t("common.retry")
) : (
t("insights.workouts.loadMore")
)}
</Button>
</li>
) : null}
</ul>
);
}
@@ -0,0 +1,140 @@
import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { afterEach, describe, expect, it, vi } from "vitest";
import { I18nProvider } from "@/lib/i18n/context";
import { handleFilePickerChange, OcrReviewDialog } from "../ocr-review-dialog";
const hookState = vi.hoisted(() => ({ extractPending: false }));
vi.mock("@/components/ui/responsive-sheet", () => ({
ResponsiveSheet: ({
children,
title,
description,
}: {
children: ReactNode;
title: string;
description: string;
}) => (
<section aria-label={title}>
<p>{description}</p>
{children}
</section>
),
}));
vi.mock("../use-ocr-extract", () => ({
useOcrExtract: () => ({
isPending: hookState.extractPending,
mutate: vi.fn(),
reset: vi.fn(),
}),
useOcrTextExtract: () => ({
isPending: hookState.extractPending,
mutate: vi.fn(),
reset: vi.fn(),
}),
useOcrCommit: () => ({
isPending: false,
mutate: vi.fn(),
reset: vi.fn(),
}),
}));
vi.mock("sonner", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
function render(
locale: "en" | "de" = "en",
options: { mode?: "vision" | "text"; pdfSupported?: boolean } = {},
): string {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<OcrReviewDialog
open
onOpenChange={() => {}}
mode={options.mode ?? "vision"}
pdfSupported={options.pdfSupported ?? true}
onCommitted={() => {}}
/>
</I18nProvider>,
);
}
function pickerMarkup(html: string): { input: string; label: string } {
const input = html.match(/<input[^>]*type="file"[^>]*>/)?.[0];
const label = html.match(/<label[^>]*>[\s\S]*?<\/label>/)?.[0];
expect(input).toBeDefined();
expect(label).toBeDefined();
return { input: input!, label: label! };
}
afterEach(() => {
hookState.extractPending = false;
});
describe("<OcrReviewDialog> file picker", () => {
it.each([
["en", "Scan a report"],
["de", "Befund scannen"],
] as const)(
"renders one natively labelled, localized picker in %s",
(locale, localizedName) => {
const html = render(locale);
const { input, label } = pickerMarkup(html);
const inputId = input.match(/id="([^"]+)"/)?.[1];
expect(html.match(/<input[^>]*type="file"/g)).toHaveLength(1);
expect(html).not.toContain("<button");
expect(inputId).toBeTruthy();
expect(label).toContain(`for="${inputId}"`);
expect(label).toContain(localizedName);
},
);
it("uses the native file input as the sole keyboard focus target and paints its focus on the visible label", () => {
const html = render();
const { input, label } = pickerMarkup(html);
expect(input).not.toMatch(/\sdisabled(?:[=\s/>])/);
expect(input).not.toContain('tabindex="-1"');
expect(input).not.toContain("aria-hidden");
expect(input).toContain("peer");
expect(label).not.toContain("tabindex=");
expect(label).toContain("peer-focus-visible:ring-2");
expect(label).toContain("peer-focus-visible:ring-ring");
});
it("keeps the mode-specific MIME restrictions and disables the native picker while extracting", () => {
const visionInput = pickerMarkup(render()).input;
const textInput = pickerMarkup(
render("en", { mode: "text", pdfSupported: true }),
).input;
expect(visionInput).toContain(
'accept="image/jpeg,image/png,image/webp,application/pdf"',
);
expect(textInput).toContain('accept="image/jpeg,image/png,image/webp"');
hookState.extractPending = true;
const pending = pickerMarkup(render());
expect(pending.input).toMatch(/\sdisabled(?:[=\s/>])/);
expect(pending.label).toContain("peer-disabled:cursor-not-allowed");
expect(pending.label).toContain("Reading your report");
});
it("passes the first selected file to the callback and clears the native value", () => {
const selected = { name: "report.pdf" } as File;
const input = { files: [selected], value: "C:\\fakepath\\report.pdf" };
const onFilePicked = vi.fn();
handleFilePickerChange(input, onFilePicked);
expect(onFilePicked).toHaveBeenCalledOnce();
expect(onFilePicked).toHaveBeenCalledWith(selected);
expect(input.value).toBe("");
});
});
+3 -6
View File
@@ -18,7 +18,8 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { ApiError, apiPost, apiPut } from "@/lib/api/api-fetch";
import { apiPost, apiPut } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import {
BIOMARKER_CATALOG,
BIOMARKER_PANELS,
@@ -131,11 +132,7 @@ export function BiomarkerForm({
);
onSuccess?.(saved);
} catch (err) {
const message =
err instanceof ApiError
? err.message
: t("labs.biomarker.form.saveError");
setError(message);
setError(localizedApiError(err, t, "labs.biomarker.form.saveError"));
} finally {
setSubmitting(false);
}
+3 -4
View File
@@ -19,7 +19,8 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { ApiError, apiGet, apiPost } from "@/lib/api/api-fetch";
import { apiGet, apiPost } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { formatReferenceRange } from "@/lib/labs/reference-range";
import { formatLabValue } from "@/lib/labs/format-value";
import { useTranslations } from "@/lib/i18n/context";
@@ -226,9 +227,7 @@ export function LabForm({
onSuccess?.(created);
}
} catch (err) {
const message =
err instanceof ApiError ? err.message : t("labs.form.saveError");
setError(message);
setError(localizedApiError(err, t, "labs.form.saveError"));
} finally {
setPendingAction(null);
}
+52 -15
View File
@@ -11,6 +11,10 @@ import { DateTimeField } from "@/components/ui/date-time-field";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import {
SeededFormDiscardDialog,
useSeededFormDismissal,
} from "@/components/forms/use-seeded-form-dismissal";
import {
Table,
TableBody,
@@ -21,13 +25,8 @@ import {
} from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import { useTableSort } from "@/hooks/use-table-sort";
import {
ApiError,
apiDelete,
apiGet,
apiPost,
apiPut,
} from "@/lib/api/api-fetch";
import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { formatDateShort } from "@/lib/format";
import { formatLabReading } from "@/lib/labs/format-value";
import { resolveNoteForUpdate } from "@/lib/labs/note-update";
@@ -92,6 +91,12 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
const [editValueText, setEditValueText] = useState("");
const [editTakenAt, setEditTakenAt] = useState("");
const [editNote, setEditNote] = useState("");
const [editSeed, setEditSeed] = useState({
value: "",
valueText: "",
takenAt: "",
note: "",
});
const [editLoading, setEditLoading] = useState(false);
const [editError, setEditError] = useState<string | null>(null);
// True when the decrypted-note GET failed to load for the row being edited.
@@ -149,21 +154,44 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
onSuccess: () => {
invalidate();
toast.success(t("labs.editedToast"));
closeEdit();
setEditSeed({
value: editValue,
valueText: editValueText,
takenAt: editTakenAt,
note: editNote,
});
dismissEdit();
},
onError: (err) => {
setEditError(err instanceof ApiError ? err.message : t("labs.editError"));
setEditError(localizedApiError(err, t, "labs.editError"));
},
});
const editDismissal = useSeededFormDismissal({
seed: editSeed,
value: {
value: editValue,
valueText: editValueText,
takenAt: editTakenAt,
note: editNote,
},
blocked: updateMutation.isPending,
});
async function openEdit(reading: LabResultDto) {
const seed = {
value: reading.value !== null ? String(reading.value) : "",
valueText: reading.valueText ?? "",
takenAt: toDateTimeLocal(reading.takenAt),
note: "",
};
setEditingId(reading.id);
const qualitative = reading.value === null;
setEditIsQualitative(qualitative);
setEditValue(reading.value !== null ? String(reading.value) : "");
setEditValueText(reading.valueText ?? "");
setEditTakenAt(toDateTimeLocal(reading.takenAt));
setEditNote("");
setEditValue(seed.value);
setEditValueText(seed.valueText);
setEditTakenAt(seed.takenAt);
setEditNote(seed.note);
setEditSeed(seed);
setEditError(null);
setNoteLoadFailed(false);
setHasNote(reading.hasNote);
@@ -174,6 +202,7 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
`/api/labs/${reading.id}`,
);
setEditNote(detail.note ?? "");
setEditSeed({ ...seed, note: detail.note ?? "" });
} catch {
// The note couldn't be loaded. Flag it so the submit path omits `note`
// (preserving the stored ciphertext) rather than sending `null` and
@@ -184,12 +213,15 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
}
}
function closeEdit() {
if (updateMutation.isPending) return;
function dismissEdit() {
setEditingId(null);
setEditError(null);
}
function closeEdit() {
editDismissal.requestClose(dismissEdit);
}
function submitEdit(e: React.FormEvent) {
e.preventDefault();
if (!editingId) return;
@@ -431,6 +463,11 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
</div>
</form>
</ResponsiveSheet>
<SeededFormDiscardDialog
open={editDismissal.discardDialogOpen}
onConfirm={editDismissal.confirmDiscard}
onCancel={editDismissal.cancelDiscard}
/>
</>
);
}
+27 -18
View File
@@ -9,7 +9,7 @@
* confirms. Reuses the labs design language via `ResponsiveSheet` + the labs
* row editor.
*/
import { useMemo, useRef, useState } from "react";
import { useId, useMemo, useState } from "react";
import { Loader2, ScanLine, Upload } from "lucide-react";
import { toast } from "sonner";
@@ -35,6 +35,20 @@ export type OcrMode = "vision" | "text";
const ACCEPT = "image/jpeg,image/png,image/webp,application/pdf";
const ACCEPT_IMAGE_ONLY = "image/jpeg,image/png,image/webp";
type FilePickerInput = {
files: ArrayLike<File> | null;
value: string;
};
export function handleFilePickerChange(
input: FilePickerInput,
onFilePicked: (file: File) => void,
) {
const file = input.files?.[0];
// Reset so re-picking the same file fires `change` again.
input.value = "";
if (file) onFilePicked(file);
}
/** Map a confirmed review row to the commit payload, or null when invalid. */
function toCommitRow(row: OcrReviewRow): OcrCommitRowInput | null {
@@ -105,7 +119,7 @@ export function OcrReviewDialog({
onCommitted: () => void;
}) {
const { t } = useTranslations();
const inputRef = useRef<HTMLInputElement | null>(null);
const pickerId = useId();
const [stage, setStage] = useState<Stage>("pick");
const [rows, setRows] = useState<OcrReviewRow[]>([]);
// S9 — in vision mode the picked file is retained so, on commit, it can be
@@ -217,25 +231,20 @@ export function OcrReviewDialog({
}
>
{stage === "pick" ? (
<div className="space-y-4 py-2">
<div className="relative py-2">
<input
ref={inputRef}
id={pickerId}
type="file"
accept={allowPdf ? ACCEPT : ACCEPT_IMAGE_ONLY}
className="sr-only"
onChange={(e) => {
const file = e.target.files?.[0];
// Reset so re-picking the same file fires `change` again.
e.target.value = "";
if (file) onFilePicked(file);
}}
/>
<Button
type="button"
variant="outline"
onClick={() => inputRef.current?.click()}
disabled={extract.isPending}
className="border-muted-foreground/25 hover:bg-muted/50 h-auto min-h-44 w-full flex-col gap-3 rounded-lg border-dashed p-6 text-center whitespace-normal"
className="peer absolute inset-0 z-10 h-full w-full cursor-pointer rounded-lg opacity-0 disabled:cursor-not-allowed"
onChange={(event) =>
handleFilePickerChange(event.currentTarget, onFilePicked)
}
/>
<label
htmlFor={pickerId}
className="border-muted-foreground/25 hover:bg-muted/50 peer-focus-visible:ring-ring flex min-h-44 w-full flex-col items-center justify-center gap-3 rounded-lg border border-dashed p-6 text-center whitespace-normal peer-focus-visible:ring-2 peer-focus-visible:ring-offset-2 peer-disabled:cursor-not-allowed peer-disabled:opacity-50"
>
{extract.isPending ? (
<>
@@ -272,7 +281,7 @@ export function OcrReviewDialog({
) : null}
</>
)}
</Button>
</label>
</div>
) : (
<div className="space-y-3 py-2">
@@ -0,0 +1,183 @@
import type * as ReactModule from "react";
import { isValidElement, type ReactElement, type ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const hookState = vi.hoisted(() => ({
cursor: 0,
values: [] as unknown[],
}));
const captureState = vi.hoisted(() => ({
dirty: true,
nutrientsEnabled: true,
}));
vi.mock("react", async (importOriginal) => {
const actual = await importOriginal<typeof ReactModule>();
return {
...actual,
useState<T>(initialValue: T | (() => T)) {
const index = hookState.cursor++;
if (!(index in hookState.values)) {
hookState.values[index] =
typeof initialValue === "function"
? (initialValue as () => T)()
: initialValue;
}
const setValue = (nextValue: T | ((current: T) => T)) => {
const current = hookState.values[index] as T;
hookState.values[index] =
typeof nextValue === "function"
? (nextValue as (value: T) => T)(current)
: nextValue;
};
return [hookState.values[index] as T, setValue] as const;
},
};
});
vi.mock("@/lib/i18n/context", () => ({
useTranslations: () => ({ t: (key: string) => key }),
}));
vi.mock("@/hooks/use-module-enabled", () => ({
useModuleEnabled: () => captureState.nutrientsEnabled,
}));
vi.mock("@/components/dashboard/quick-entry-sheets", () => ({
sheetBodyHasUnsavedInput: () => captureState.dirty,
}));
function markedComponent(displayName: string) {
const Component = ({ children }: { children?: ReactNode }) => children;
Component.displayName = displayName;
return Component;
}
vi.mock("@/components/ui/responsive-sheet", () => ({
ResponsiveSheet: markedComponent("ResponsiveSheet"),
}));
vi.mock("@/components/ui/alert-dialog", () => ({
AlertDialog: markedComponent("AlertDialog"),
AlertDialogAction: markedComponent("AlertDialogAction"),
AlertDialogCancel: markedComponent("AlertDialogCancel"),
AlertDialogContent: markedComponent("AlertDialogContent"),
AlertDialogDescription: markedComponent("AlertDialogDescription"),
AlertDialogFooter: markedComponent("AlertDialogFooter"),
AlertDialogHeader: markedComponent("AlertDialogHeader"),
AlertDialogTitle: markedComponent("AlertDialogTitle"),
}));
vi.mock("@/components/measurements/measurement-form", () => ({
MeasurementForm: markedComponent("MeasurementForm"),
}));
vi.mock("@/components/mood/mood-form", () => ({
MoodForm: markedComponent("MoodForm"),
}));
vi.mock("@/components/dashboard/medication-intake-quick-add", () => ({
MedicationIntakeQuickAdd: markedComponent("MedicationIntakeQuickAdd"),
}));
vi.mock("@/components/insights/nutrients/water-quick-add-sheet", () => ({
WaterQuickAddSheet: markedComponent("WaterQuickAddSheet"),
}));
import { CapturePicker } from "../capture-picker";
type ElementProps = Record<string, unknown> & { children?: ReactNode };
type TestElement = ReactElement<ElementProps>;
function elementsIn(node: ReactNode): TestElement[] {
if (Array.isArray(node)) return node.flatMap(elementsIn);
if (!isValidElement<ElementProps>(node)) return [];
return [node, ...elementsIn(node.props.children)];
}
function renderPicker(): ReactNode {
hookState.cursor = 0;
return CapturePicker({ open: true, onOpenChange: vi.fn() });
}
function findByTestId(tree: ReactNode, testId: string): TestElement {
const element = elementsIn(tree).find(
(candidate) => candidate.props["data-testid"] === testId,
);
expect(element, `missing data-testid=${testId}`).toBeDefined();
return element!;
}
function findMarked(tree: ReactNode, displayName: string): TestElement | null {
return (
elementsIn(tree).find(
(element) =>
typeof element.type === "function" &&
"displayName" in element.type &&
element.type.displayName === displayName,
) ?? null
);
}
class WaterDraftHarness {
private mounted = false;
private amount = "";
observe(tree: ReactNode): TestElement | null {
const sheet = findMarked(tree, "WaterQuickAddSheet");
if (!sheet) {
this.mounted = false;
this.amount = "";
} else if (!this.mounted) {
this.mounted = true;
this.amount = "";
}
return sheet;
}
fill(value: string) {
expect(this.mounted).toBe(true);
this.amount = value;
}
value() {
return this.amount;
}
}
describe("<CapturePicker> — confirmed water discard", () => {
beforeEach(() => {
hookState.cursor = 0;
hookState.values.length = 0;
captureState.dirty = true;
captureState.nutrientsEnabled = true;
});
it("reopens with a fresh custom amount after confirmed discard", () => {
const draft = new WaterDraftHarness();
let tree = renderPicker();
draft.observe(tree);
const waterOption = findByTestId(tree, "capture-picker-water");
(waterOption.props.onClick as () => void)();
tree = renderPicker();
const waterSheet = draft.observe(tree);
expect(waterSheet?.props.open).toBe(true);
draft.fill("375");
(waterSheet?.props.onOpenChange as (open: boolean) => void)(false);
tree = renderPicker();
const confirmDiscard = findMarked(tree, "AlertDialogAction");
expect(confirmDiscard).not.toBeNull();
expect(draft.observe(tree)?.props.open).toBe(true);
expect(draft.value()).toBe("375");
(confirmDiscard?.props.onClick as () => void)();
tree = renderPicker();
expect(draft.observe(tree)).toBeNull();
const reopenedWaterOption = findByTestId(tree, "capture-picker-water");
(reopenedWaterOption.props.onClick as () => void)();
tree = renderPicker();
expect(draft.observe(tree)?.props.open).toBe(true);
expect(draft.value()).toBe("");
});
});
@@ -0,0 +1,70 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
const moduleGate = vi.hoisted(() => ({ nutrientsEnabled: true }));
vi.mock("@/hooks/use-module-enabled", () => ({
useModuleEnabled: (moduleKey: string) =>
moduleKey === "nutrients" ? moduleGate.nutrientsEnabled : true,
}));
vi.mock("@/components/ui/responsive-sheet", () => ({
ResponsiveSheet: ({
open,
children,
}: {
open: boolean;
children: React.ReactNode;
}) => (open ? <section>{children}</section> : null),
}));
vi.mock("@/components/measurements/measurement-form", () => ({
MeasurementForm: () => <div data-testid="measurement-form" />,
}));
vi.mock("@/components/mood/mood-form", () => ({
MoodForm: () => <div data-testid="mood-form" />,
}));
vi.mock("@/components/dashboard/medication-intake-quick-add", () => ({
MedicationIntakeQuickAdd: () => <div data-testid="medication-form" />,
}));
vi.mock("@/components/insights/nutrients/water-quick-add-sheet", () => ({
WaterQuickAddSheet: () => <div data-testid="water-quick-add-sheet" />,
}));
import { CapturePicker } from "../capture-picker";
function render() {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<CapturePicker open onOpenChange={() => undefined} />
</I18nProvider>,
);
}
describe("<CapturePicker> — nutrients module gate", () => {
it("hides and unmounts only water capture when nutrients are disabled", () => {
moduleGate.nutrientsEnabled = false;
const html = render();
expect(html).not.toContain('data-testid="capture-picker-water"');
expect(html).not.toContain('data-testid="water-quick-add-sheet"');
expect(html).toContain('data-testid="capture-picker-measurement"');
expect(html).toContain('data-testid="capture-picker-medication"');
expect(html).toContain('data-testid="capture-picker-mood"');
});
it("keeps water capture available when nutrients are enabled", () => {
moduleGate.nutrientsEnabled = true;
const html = render();
expect(html).toContain('data-testid="capture-picker-water"');
expect(html).not.toContain('data-testid="water-quick-add-sheet"');
});
});
+8 -6
View File
@@ -278,12 +278,14 @@ export function AuthShell({
it). Focus restores both — the element jumps back into the
viewport and becomes clickable.
*/}
<a
href="#main-content"
className="bg-primary text-primary-foreground pointer-events-none fixed top-2 left-2 z-[100] -translate-y-full rounded-md px-4 py-2 text-sm font-medium opacity-0 transition-transform focus:pointer-events-auto focus:translate-y-0 focus:opacity-100"
>
{t("nav.skipToContent")}
</a>
<nav aria-label={t("nav.skipToContent")}>
<a
href="#main-content"
className="bg-primary text-primary-foreground pointer-events-none fixed top-2 left-2 z-[100] -translate-y-full rounded-md px-4 py-2 text-sm font-medium opacity-0 transition-transform focus:pointer-events-auto focus:translate-y-0 focus:opacity-100"
>
{t("nav.skipToContent")}
</a>
</nav>
<div className="flex h-dvh flex-col md:flex-row">
<SidebarNav />
{/* `min-w-0` lets the content column shrink below its children's
+47 -27
View File
@@ -1,11 +1,12 @@
"use client";
import { useState } from "react";
import { Activity, Pill, Waves } from "lucide-react";
import { Activity, GlassWater, 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 { WaterQuickAddSheet } from "@/components/insights/nutrients/water-quick-add-sheet";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import {
AlertDialog,
@@ -19,20 +20,20 @@ import {
} from "@/components/ui/alert-dialog";
import { sheetBodyHasUnsavedInput } from "@/components/dashboard/quick-entry-sheets";
import { useTranslations } from "@/lib/i18n/context";
import { useModuleEnabled } from "@/hooks/use-module-enabled";
/**
* 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 one of three existing quick-entry surfaces:
* picker that routes to four existing quick-entry surfaces:
*
* - Measurement → `<MeasurementForm>` (the `/measurements` add form)
* - Medication → `<MedicationIntakeQuickAdd>` (the dashboard intake
* quick-add)
* - Medication → `<MedicationIntakeQuickAdd>` (dashboard quick-add)
* - Mood → `<MoodForm>` (the `/mood` add form, 5-face flow)
* - Water → `<WaterQuickAddSheet>` (the hydration quick-add)
*
* All three forms share the `{ onSuccess, onCancel, footerSlot }`
* contract and are designed to mount inside `<ResponsiveSheet>`, so the
* picker reuses them verbatim rather than rebuilding any capture UI.
* The picker reuses each existing capture component rather than
* rebuilding parallel forms.
*
* Nothing here orphans a route: the same surfaces remain reachable
* from their own pages (`/measurements`, `/mood`, dashboard) and from
@@ -45,7 +46,7 @@ import { useTranslations } from "@/lib/i18n/context";
* instead of closing unconditionally.
*/
type CaptureKind = "measurement" | "medication" | "mood";
type CaptureKind = "measurement" | "medication" | "mood" | "water";
interface CapturePickerProps {
/** Whether the picker chooser sheet is open. */
@@ -56,6 +57,7 @@ interface CapturePickerProps {
export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
const { t } = useTranslations();
const nutrientsEnabled = useModuleEnabled("nutrients");
const [kind, setKind] = useState<CaptureKind | null>(null);
const [footerEl, setFooterEl] = useState<HTMLDivElement | null>(null);
// v1.30.1 — mirrors `QuickEntrySheets`' `confirmDiscardOpen`: hold a
@@ -64,6 +66,7 @@ export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
const [confirmDiscardOpen, setConfirmDiscardOpen] = useState(false);
function chooseKind(next: CaptureKind) {
if (next === "water" && !nutrientsEnabled) return;
// Close the chooser first, then open the form sheet so only one
// bottom-sheet is mounted at a time (no stacked backdrops).
onOpenChange(false);
@@ -110,12 +113,19 @@ export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
description: t("nav.capture.moodDescription"),
icon: Waves,
},
{
kind: "water",
label: t("nav.capture.water"),
description: t("nav.capture.waterDescription"),
icon: GlassWater,
},
];
const formTitleByKind: Record<CaptureKind, string> = {
measurement: t("measurements.addMeasurement"),
medication: t("nav.capture.medication"),
mood: t("mood.addEntry"),
water: t("nutrients.hydration.quickAddTitle"),
};
// `kind === null` keeps the form sheet closed (the title is unread then);
// the mood label is the harmless default, matching the prior ternary's
@@ -135,31 +145,33 @@ export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
className="grid grid-cols-1 gap-2"
data-testid="capture-picker-options"
>
{options.map((opt) => (
<button
key={opt.kind}
type="button"
data-testid={`capture-picker-${opt.kind}`}
onClick={() => chooseKind(opt.kind)}
className="border-border hover:bg-accent/40 focus-visible:ring-ring/50 flex min-h-14 items-center gap-3 rounded-lg border px-4 py-3 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
<span className="bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-full">
<opt.icon className="h-5 w-5" aria-hidden="true" />
</span>
<span className="min-w-0">
<span className="block text-sm font-medium">{opt.label}</span>
<span className="text-muted-foreground block text-xs">
{opt.description}
{options.map((opt) =>
opt.kind !== "water" || nutrientsEnabled ? (
<button
key={opt.kind}
type="button"
data-testid={`capture-picker-${opt.kind}`}
onClick={() => chooseKind(opt.kind)}
className="border-border hover:bg-accent/40 focus-visible:ring-ring/50 flex min-h-14 items-center gap-3 rounded-lg border px-4 py-3 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
<span className="bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-full">
<opt.icon className="h-5 w-5" aria-hidden="true" />
</span>
</span>
</button>
))}
<span className="min-w-0">
<span className="block text-sm font-medium">{opt.label}</span>
<span className="text-muted-foreground block text-xs">
{opt.description}
</span>
</span>
</button>
) : null,
)}
</div>
</ResponsiveSheet>
{/* The chosen capture surface, reusing the existing form. */}
<ResponsiveSheet
open={kind !== null}
open={kind !== null && kind !== "water"}
onOpenChange={handleFormOpenChange}
title={formTitle}
footer={<div ref={setFooterEl} className="flex w-full" />}
@@ -187,6 +199,14 @@ export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
)}
</ResponsiveSheet>
{nutrientsEnabled && kind === "water" && (
<WaterQuickAddSheet
open
onOpenChange={handleFormOpenChange}
onSuccess={closeForm}
/>
)}
{/* v1.30.1 — confirm before discarding a partly-filled capture
form when the sheet is dismissed by an overlay tap, Escape,
or a mobile swipe-down. Copy shared with the dashboard
@@ -29,7 +29,8 @@ import {
measurementDependentKeys,
refetchInactiveDailyReads,
} from "@/lib/query-keys";
import { ApiError, apiPost } from "@/lib/api/api-fetch";
import { apiPost } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { MEASUREMENT_NOTES_MAX_LENGTH } from "@/lib/validations/measurement";
import {
getLastUsedMeasurementType,
@@ -351,11 +352,7 @@ export function MeasurementForm({
toast.success(t("common.saved"));
onSuccess?.();
} catch (err) {
setError(
err instanceof ApiError && err.message
? err.message
: t("measurements.saveError"),
);
setError(localizedApiError(err, t, "measurements.saveError"));
} finally {
setLoading(false);
}
@@ -27,6 +27,10 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import {
SeededFormDiscardDialog,
useSeededFormDismissal,
} from "@/components/forms/use-seeded-form-dismissal";
import {
DropdownMenu,
DropdownMenuContent,
@@ -357,6 +361,11 @@ export function MeasurementList({
const [editValue, setEditValue] = useState("");
const [editMeasuredAt, setEditMeasuredAt] = useState("");
const [editNotes, setEditNotes] = useState("");
const [editSeed, setEditSeed] = useState({
value: "",
measuredAt: "",
notes: "",
});
const [editError, setEditError] = useState<string | null>(null);
const [editDeleteDialogOpen, setEditDeleteDialogOpen] = useState(false);
// v1.4.27 MB3 — link the edit dialog error banner to the inputs via
@@ -642,8 +651,12 @@ export function MeasurementList({
onSuccess: async () => {
await invalidateKeys(queryClient, measurementDependentKeys);
await refetchInactiveDailyReads(queryClient);
setEditing(null);
setEditError(null);
setEditSeed({
value: editValue,
measuredAt: editMeasuredAt,
notes: editNotes,
});
dismissEdit();
},
onError: (err) => {
// v1.4.28 FB-B1 — the PUT route returns a 409 with
@@ -672,6 +685,15 @@ export function MeasurementList({
);
},
});
const editDismissal = useSeededFormDismissal({
seed: editSeed,
value: {
value: editValue,
measuredAt: editMeasuredAt,
notes: editNotes,
},
blocked: updateMutation.isPending || deleteMutation.isPending,
});
// v1.4.37 W7c — the collapsed daily view paints up to 5000
// synthesised rows in one shot (one row per day for the
@@ -688,27 +710,36 @@ export function MeasurementList({
return;
}
const seed = {
value: String(measurement.value),
measuredAt: toDateTimeLocalValue(measurement.measuredAt),
notes: (measurement.notes ?? "").slice(0, MAX_COMMENT_LENGTH),
};
setEditing(measurement);
setEditValue(String(measurement.value));
setEditMeasuredAt(toDateTimeLocalValue(measurement.measuredAt));
setEditNotes((measurement.notes ?? "").slice(0, MAX_COMMENT_LENGTH));
setEditValue(seed.value);
setEditMeasuredAt(seed.measuredAt);
setEditNotes(seed.notes);
setEditSeed(seed);
setEditError(null);
}
function closeEdit() {
if (updateMutation.isPending || deleteMutation.isPending) return;
function dismissEdit() {
setEditing(null);
setEditError(null);
setEditDeleteDialogOpen(false);
}
function closeEdit() {
editDismissal.requestClose(dismissEdit);
}
async function deleteEditingMeasurement() {
if (!editing) return;
try {
setEditError(null);
await deleteMutation.mutateAsync(editing.id);
closeEdit();
dismissEdit();
} catch {
setEditError(t("measurements.deleteError"));
}
@@ -1557,6 +1588,11 @@ export function MeasurementList({
</form>
)}
</ResponsiveSheet>
<SeededFormDiscardDialog
open={editDismissal.discardDialogOpen}
onConfirm={editDismissal.confirmDiscard}
onCancel={editDismissal.cancelDiscard}
/>
</>
);
}
@@ -0,0 +1,129 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
}));
vi.mock("sonner", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { username: "tester", timezone: "Europe/Berlin" },
}),
}));
vi.mock("@/components/ui/responsive-sheet", () => ({
ResponsiveSheet: ({
open,
children,
footer,
}: {
open: boolean;
children: React.ReactNode;
footer?: React.ReactNode;
}) =>
open ? (
<section>
{children}
{footer}
</section>
) : null,
}));
import {
LogIntakeDialog,
type LogIntakeMedication,
} from "../log-intake-dialog";
function medication(
id: string,
overrides: Partial<LogIntakeMedication> = {},
): LogIntakeMedication {
return {
id,
name: `Medication ${id}`,
dose: "5 mg",
active: true,
schedules: [],
lastTakenAt: null,
todayEventCount: 0,
nextDueAt: null,
nextDueOverdue: false,
...overrides,
};
}
function renderDialog(medications: LogIntakeMedication[]): string {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<LogIntakeDialog open onOpenChange={() => {}} medications={medications} />
</I18nProvider>,
);
}
function expectSelected(html: string, id: string): void {
expect(html).toMatch(
new RegExp(
`<option(?=[^>]*value="${id}")(?=[^>]*selected)[^>]*>|<option(?=[^>]*selected)(?=[^>]*value="${id}")[^>]*>`,
),
);
}
describe("<LogIntakeDialog> medication default", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-12T08:00:00.000Z"));
});
afterEach(() => {
vi.useRealTimers();
});
it("selects an overdue current-window medication ahead of array order", () => {
const html = renderDialog([
medication("first", { name: "Alpha" }),
medication("overdue", {
name: "Zulu",
schedules: [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
label: null,
dose: null,
},
],
nextDueAt: "2026-05-12T06:00:00.000Z",
nextDueOverdue: true,
}),
]);
expectSelected(html, "overdue");
});
it("uses a stable alphabetical fallback instead of array order for future doses", () => {
const html = renderDialog([
medication("z", {
name: "Zolpidem",
schedules: [
{
windowStart: "08:00",
windowEnd: "11:00",
daysOfWeek: null,
label: null,
dose: null,
},
],
nextDueAt: "2099-05-13T07:00:00.000Z",
nextDueOverdue: false,
}),
medication("r", { name: "Ramipril" }),
]);
expectSelected(html, "r");
});
});
@@ -1,8 +1,44 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import type { ReactNode } from "react";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import en from "../../../../messages/en.json";
const errorCardCapture = vi.hoisted(() => ({
onRetry: undefined as (() => void) | undefined,
}));
type QueryErrorCardProps = {
title?: ReactNode;
description?: ReactNode;
onRetry?: () => void;
retryLabel?: string;
className?: string;
};
type QueryErrorCardModule = {
QueryErrorCard: (props: QueryErrorCardProps) => ReactNode;
};
vi.mock("@/components/ui/query-error-card", async () => {
// `vi.importActual` is required here so the wrapper can capture the retry
// callback while preserving the shared card's real accessible markup/copy.
const actual = await vi.importActual<QueryErrorCardModule>(
"@/components/ui/query-error-card",
);
return {
...actual,
QueryErrorCard: (props: QueryErrorCardProps) => {
errorCardCapture.onRetry = props.onRetry;
return actual.QueryErrorCard(props);
},
};
});
import { SideEffectsSection } from "@/components/medications/side-effects-section";
/**
@@ -32,6 +68,7 @@ function makeClient() {
// Synchronous resolution of seeded data is the test contract.
staleTime: Number.POSITIVE_INFINITY,
refetchOnMount: false,
retryOnMount: false,
},
},
});
@@ -49,7 +86,7 @@ function seedSideEffects(
notes: string | null;
}>,
) {
client.setQueryData(["medications", medId, "side-effects", "list"], {
client.setQueryData(queryKeys.medicationSideEffects(medId), {
items: rows,
meta: { total: rows.length },
});
@@ -69,6 +106,138 @@ function render(
beforeEach(() => {
vi.clearAllMocks();
errorCardCapture.onRetry = undefined;
});
afterEach(() => {
vi.unstubAllGlobals();
});
function emptySideEffectsResponse(): Response {
return new Response(
JSON.stringify({
data: { items: [], meta: { total: 0 } },
error: null,
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
);
}
async function executeSideEffectsQuery(
client: QueryClient,
medicationId: string,
): Promise<void> {
const queryKey = queryKeys.medicationSideEffects(medicationId);
const queryFn = client.getQueryCache().find({ queryKey, exact: true })
?.options.queryFn;
if (typeof queryFn !== "function") {
throw new Error("Side-effects query function was not registered");
}
try {
await client.fetchQuery({ queryKey, queryFn });
} catch {
// The settled error is the state under test.
}
}
async function waitForQueryStatus(
client: QueryClient,
medicationId: string,
status: "error" | "success",
): Promise<void> {
await vi.waitFor(() => {
expect(
client.getQueryState(queryKeys.medicationSideEffects(medicationId))
?.status,
).toBe(status);
});
}
describe("<SideEffectsSection> — failed reads", () => {
it.each([
{
failure: "HTTP",
response: () =>
Promise.resolve(
new Response(
JSON.stringify({ data: null, error: "service unavailable" }),
{
status: 503,
headers: { "Content-Type": "application/json" },
},
),
),
},
{
failure: "non-JSON",
response: () =>
Promise.resolve(
new Response("<html>not json</html>", {
status: 200,
headers: { "Content-Type": "text/html" },
}),
),
},
{
failure: "rejected",
response: () => Promise.reject(new TypeError("network unavailable")),
},
])(
"renders Retry instead of the empty state after a $failure failure",
async ({ response }) => {
const client = makeClient();
const fetchMock = vi.fn(response);
vi.stubGlobal("fetch", fetchMock);
render(<SideEffectsSection medicationId="med-failure" />, client);
await executeSideEffectsQuery(client, "med-failure");
await waitForQueryStatus(client, "med-failure", "error");
expect(fetchMock).toHaveBeenCalledTimes(1);
const html = render(
<SideEffectsSection medicationId="med-failure" />,
client,
);
expect(html).toContain('data-slot="query-error-card"');
expect(html).toContain('role="alert"');
expect(html).toContain(en.common.loadFailed);
expect(html).toContain(en.common.retry);
expect(html).not.toContain(en.medications.sideEffects.emptyState);
},
);
it("retries the failed query and renders the genuine empty response", async () => {
const client = makeClient();
const fetchMock = vi
.fn()
.mockRejectedValueOnce(new TypeError("network unavailable"))
.mockResolvedValueOnce(emptySideEffectsResponse());
vi.stubGlobal("fetch", fetchMock);
render(<SideEffectsSection medicationId="med-retry" />, client);
await executeSideEffectsQuery(client, "med-retry");
await waitForQueryStatus(client, "med-retry", "error");
const errorHtml = render(
<SideEffectsSection medicationId="med-retry" />,
client,
);
expect(errorHtml).toContain(en.common.retry);
expect(errorCardCapture.onRetry).toBeTypeOf("function");
errorCardCapture.onRetry?.();
await waitForQueryStatus(client, "med-retry", "success");
expect(fetchMock).toHaveBeenCalledTimes(2);
const recoveredHtml = render(
<SideEffectsSection medicationId="med-retry" />,
client,
);
expect(recoveredHtml).not.toContain('data-slot="query-error-card"');
expect(recoveredHtml).toContain(en.medications.sideEffects.emptyState);
});
});
describe("<SideEffectsSection> — surface render", () => {
@@ -34,6 +34,10 @@ import { Textarea } from "@/components/ui/textarea";
import { useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
import { ApiError, apiPost } from "@/lib/api/api-fetch";
import {
MedicationIntakeImportError,
waitForMedicationIntakeImport,
} from "@/lib/medications/intake-import-poll";
export interface IntakeImportDialogProps {
medicationId: string | null;
@@ -104,11 +108,12 @@ export function IntakeImportDialog({
}
try {
const d = await apiPost<{
imported: number;
skippedDuplicates: number;
skippedInvalid: number;
const kickoff = await apiPost<{
jobId: string;
status: "queued";
statusUrl: string;
}>(`/api/medications/${medicationId}/intake/import`, data);
const d = await waitForMedicationIntakeImport(kickoff.statusUrl);
setResult(
t("medications.importResult", { imported: d.imported }) +
(d.skippedDuplicates > 0
@@ -121,6 +126,11 @@ export function IntakeImportDialog({
setResultType("success");
void invalidateKeys(queryClient, medicationDependentKeys);
} catch (err) {
if (err instanceof MedicationIntakeImportError) {
setResult(t("medications.importFailed"));
setResultType("error");
return;
}
if (!(err instanceof ApiError)) throw err;
setResult(err.message || t("medications.importFailed"));
setResultType("error");
@@ -32,22 +32,24 @@ import { DateTimeField } from "@/components/ui/date-time-field";
import { NativeSelect } from "@/components/ui/native-select";
import { Switch } from "@/components/ui/switch";
import { useTranslations } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth";
import {
resolveMedicationSelectionId,
type DefaultMedicationOption,
} from "@/lib/medications/default-medication";
import type { ScheduleWindowInput } from "@/lib/medications/window-status";
import { formatDose } from "@/lib/medications/format-dose";
import {
runLogIntake,
runUndoIntake,
} from "@/components/medications/use-medication-intake";
interface LogIntakeSchedule {
windowStart: string;
interface LogIntakeSchedule extends ScheduleWindowInput {
label: string | null;
dose: string | null;
timesOfDay?: string[];
}
export interface LogIntakeMedication {
id: string;
name: string;
export interface LogIntakeMedication extends DefaultMedicationOption {
dose: string;
schedules: LogIntakeSchedule[];
}
@@ -115,11 +117,22 @@ export function LogIntakeDialog({
}: LogIntakeDialogProps) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const { user } = useAuth();
const userTz = user?.timezone || "Europe/Berlin";
const formId = useId();
const [medicationId, setMedicationId] = useState<string>(
() => medications[0]?.id ?? "",
const [medicationOverride, setMedicationOverride] = useState<string | null>(
null,
);
const [selectionNow] = useState(() => new Date());
const medicationId =
resolveMedicationSelectionId(
medications,
medicationOverride,
selectionNow,
undefined,
userTz,
) ?? "";
const [slot, setSlot] = useState<string>(NO_SLOT);
const [takenAt, setTakenAt] = useState<string>(() =>
toDateTimeLocal(new Date()),
@@ -156,7 +169,7 @@ export function LogIntakeDialog({
const dose = doseOverride ?? configuredDose;
function handleMedicationChange(id: string) {
setMedicationId(id);
setMedicationOverride(id);
// Reset the slot when switching medications — the prior slot may not
// exist on the new schedule. The dose override resets too so the new
// medication's configured dose flows through.
@@ -67,6 +67,17 @@ function render(
);
}
function actionMarkup(html: string, slot: string): string[] {
return (
html.match(
new RegExp(
`<button(?=[^>]*data-slot="${slot}")[^>]*>[\\s\\S]*?</button>`,
"g",
),
) ?? []
);
}
const MANUAL_ERA: ScheduleRevisionRow = {
id: "rev-manual",
validFrom: "2026-03-12T00:00:00.000Z",
@@ -142,6 +153,27 @@ describe("ScheduleHistoryTimeline", () => {
expect(html).toContain("Frühere Ären ausblenden");
});
it("uses non-overlapping 44px mobile era actions with compact icons", () => {
const client = makeClient();
seed(client, {
currentSince: "2026-06-01T00:00:00.000Z",
revisions: [MANUAL_ERA, ARCHIVED_ERA],
});
const html = render(client, { defaultExpanded: true });
const editButtons = actionMarkup(html, "zeitplan-history-edit");
const deleteButtons = actionMarkup(html, "zeitplan-history-delete");
expect(editButtons).toHaveLength(2);
expect(deleteButtons).toHaveLength(1);
for (const button of [...editButtons, ...deleteButtons]) {
expect(button).toContain("size-11");
expect(button).toContain("sm:size-8");
expect(button).toContain("size-4");
}
expect(editButtons[0]).toContain('aria-label="Ära bearbeiten"');
expect(deleteButtons[0]).toContain('aria-label="Ära löschen"');
expect(html).toContain('class="flex shrink-0 items-center"');
});
it("renders the add-era CTA without a toggle when no era exists", () => {
const client = makeClient();
seed(client, { currentSince: "2026-06-01T00:00:00.000Z", revisions: [] });
@@ -49,6 +49,7 @@ import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useTranslations } from "@/lib/i18n/context";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import type { MedicationExtractionResult } from "@/lib/ai/coach/medication-extract-prompt";
@@ -135,7 +136,7 @@ export function NaturalLanguageExtractor({
abortRef.current = controller;
try {
const res = await fetch(endpoint, {
const res = await apiFetchRaw(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: trimmed, locale }),
@@ -225,7 +225,7 @@ export function ScheduleHistoryTimeline({
type="button"
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground size-8"
className="text-muted-foreground hover:text-foreground size-11 sm:size-8"
aria-label={t(
"medications.detail.zeitplan.history.editAria",
)}
@@ -239,7 +239,7 @@ export function ScheduleHistoryTimeline({
type="button"
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-destructive size-8"
className="text-muted-foreground hover:text-destructive size-11 sm:size-8"
aria-label={t(
"medications.detail.zeitplan.history.deleteAria",
)}
@@ -25,6 +25,7 @@ import { Download, Loader2, Upload } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { useTranslations } from "@/lib/i18n/context";
import { apiFetchRaw } from "@/lib/api/api-fetch";
export interface DataPortabilityRowProps {
medicationId: string;
@@ -33,7 +34,7 @@ export interface DataPortabilityRowProps {
}
async function downloadFromUrl(url: string, filename: string): Promise<void> {
const res = await fetch(url, { credentials: "include" });
const res = await apiFetchRaw(url, { credentials: "include" });
if (!res.ok) {
throw new Error(`Download failed (${res.status})`);
}
@@ -8,6 +8,7 @@ import { Loader2, Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import {
AlertDialog,
AlertDialogAction,
@@ -42,7 +43,7 @@ import {
severityLikertLabel,
} from "@/lib/medications/side-effects/taxonomy";
import { MedicationDetailSection } from "@/components/medications/medication-detail-section";
import { apiDelete, apiPost } from "@/lib/api/api-fetch";
import { apiDelete, apiFetchEnvelope, apiPost } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
/**
@@ -128,19 +129,18 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
const listKey = queryKeys.medicationSideEffects(medicationId);
const { data, isLoading } = useQuery({
const { data, isLoading, isError, refetch } = useQuery({
queryKey: listKey,
queryFn: async () => {
// Last 30 days, newest-first.
const to = new Date();
const from = new Date(to.getTime() - 30 * 24 * 60 * 60 * 1000);
const url = `/api/medications/${medicationId}/side-effects?from=${encodeURIComponent(from.toISOString())}&to=${encodeURIComponent(to.toISOString())}&limit=100`;
const res = await fetch(url);
if (!res.ok) return { items: [] as SideEffectRow[], meta: { total: 0 } };
const json = (await res.json()) as {
data: { items: SideEffectRow[]; meta: { total: number } };
};
return json.data;
const { data: payload } = await apiFetchEnvelope<{
items: SideEffectRow[];
meta: { total: number };
}>(url);
return payload;
},
staleTime: 30 * 1000,
});
@@ -272,13 +272,17 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
</div>
)}
{!isLoading && items.length === 0 && (
{!isLoading && isError && (
<QueryErrorCard onRetry={() => void refetch()} />
)}
{!isLoading && !isError && items.length === 0 && (
<p className="text-muted-foreground py-1">
{t("medications.sideEffects.emptyState")}
</p>
)}
{!isLoading && items.length > 0 && (
{!isLoading && !isError && items.length > 0 && (
<div className="space-y-2">
<p className="text-muted-foreground text-xs tracking-wide uppercase">
{t("medications.sideEffects.recentTitle")}
@@ -47,6 +47,7 @@ import {
import { Button } from "@/components/ui/button";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { useTranslations } from "@/lib/i18n/context";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
import { NaturalLanguageExtractor } from "@/components/medications/scheduling/natural-language-extractor";
@@ -315,7 +316,7 @@ function WizardDialogShell({
: "/api/medications";
const method = mode === "edit" ? "PUT" : "POST";
const res = await fetch(url, {
const res = await apiFetchRaw(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
@@ -3,6 +3,8 @@
import { useEffect, useRef } from "react";
import { useReportWebVitals } from "next/web-vitals";
import { apiFetchRaw } from "@/lib/api/api-fetch";
/**
* v1.4.28 R3d — client-side mount that pipes Web Vitals measurements
* to `/api/internal/web-vitals`.
@@ -78,7 +80,7 @@ export function WebVitalsReporter() {
// Fallback for non-`sendBeacon` browsers — `keepalive` keeps the
// request alive past navigation. We deliberately ignore the
// response (it's a beacon, not a transaction).
void fetch(url, {
void apiFetchRaw(url, {
method: "POST",
body,
headers: { "Content-Type": "application/json" },
@@ -145,7 +145,10 @@ describe("MoodList — rated factor editing", () => {
expect(listSrc).toContain(
"const [editRatedFactors, setEditRatedFactors] = useState<",
);
expect(listSrc).toContain("setEditRatedFactors(entry.ratedFactors ?? [])");
expect(listSrc).toContain(
"ratedFactors: [...(entry.ratedFactors ?? [])].sort",
);
expect(listSrc).toContain("setEditRatedFactors(seed.ratedFactors)");
expect(listSrc).toMatch(
/<MoodTagPicker[\s\S]*ratedFactors=\{editRatedFactors\}[\s\S]*onRateFactor=\{rateEditFactor\}/,
);
+20 -24
View File
@@ -42,7 +42,8 @@ import {
moodDependentKeys,
refetchInactiveDailyReads,
} from "@/lib/query-keys";
import { ApiError, apiPost } from "@/lib/api/api-fetch";
import { apiPost } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { SheetSection, SheetSectionCount } from "@/components/ui/sheet-section";
import { MoodTagPicker, type RatedFactor } from "./mood-tag-picker";
import { MoodQuickTags } from "./mood-quick-tags";
@@ -224,11 +225,7 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
toast.success(t("common.saved"));
onSuccess?.();
} catch (err) {
setError(
err instanceof ApiError && err.message
? err.message
: t("mood.saveError"),
);
setError(localizedApiError(err, t, "mood.saveError"));
} finally {
setLoading(false);
}
@@ -287,8 +284,7 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
// v1.17.0 — the "Note & details" badge counts the free-text tags entered
// plus the note (when present), so a collapsed section communicates what
// it holds. The auto-populated timestamp is excluded (it always carries a
// default, so it would never read as "empty").
// it holds.
const freeTextTagCount = tagsInput
.split(",")
.map((s) => s.trim())
@@ -365,6 +361,20 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
onExpand={() => setMoreTagsOpen(true)}
/>
<FieldGroup htmlFor="mood-logged-at" label={t("mood.timestamp")}>
<DateTimeField
id="mood-logged-at"
value={moodLoggedAt}
onChange={setMoodLoggedAt}
// v1.17 W1b — match the server bound: no future instant.
max={getDefaultMoodLoggedAtValue()}
required
aria-required="true"
aria-invalid={!!error || undefined}
aria-describedby={errorDescriptor}
/>
</FieldGroup>
{/* v1.17.0 — "More tags": the full structured-tag taxonomy
(binary tiles + custom). Controlled so the Quick row's "+" can
open it; the badge reports how many tags are selected. */}
@@ -399,8 +409,8 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
</SheetSection>
{/* v1.17.0 — "Note & details": free-text tags, GLP-1 quick-tags,
the note field, and the timestamp. The badge counts whichever
of these carry input. */}
and the note field. The badge counts whichever of these carry
input. */}
<SheetSection
title={t("mood.sectionNoteDetails")}
icon={<StickyNote />}
@@ -518,20 +528,6 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
rows={3}
/>
</FieldGroup>
<FieldGroup htmlFor="mood-logged-at" label={t("mood.timestamp")}>
<DateTimeField
id="mood-logged-at"
value={moodLoggedAt}
onChange={setMoodLoggedAt}
// v1.17 W1b — match the server bound: no future instant.
max={getDefaultMoodLoggedAtValue()}
required
aria-required="true"
aria-invalid={!!error || undefined}
aria-describedby={errorDescriptor}
/>
</FieldGroup>
</SheetSection>
</div>
)}
+74 -12
View File
@@ -23,6 +23,10 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import {
SeededFormDiscardDialog,
useSeededFormDismissal,
} from "@/components/forms/use-seeded-form-dismissal";
import {
DropdownMenu,
DropdownMenuContent,
@@ -83,6 +87,7 @@ import {
import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group";
import { MoodTagPicker, type RatedFactor } from "./mood-tag-picker";
import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
// Re-export the score map under the legacy local name to keep the
// rest of this file unchanged. v1.4.27 B6 / BL-P6-11 — the single
@@ -178,6 +183,21 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
const [editRatedFactors, setEditRatedFactors] = useState<RatedFactor[]>([]);
const [editNote, setEditNote] = useState("");
const [editMoodLoggedAt, setEditMoodLoggedAt] = useState("");
const [editSeed, setEditSeed] = useState<{
mood: string;
tagsInput: string;
tagKeys: string[];
ratedFactors: RatedFactor[];
note: string;
moodLoggedAt: string;
}>({
mood: "",
tagsInput: "",
tagKeys: [],
ratedFactors: [],
note: "",
moodLoggedAt: "",
});
const [editError, setEditError] = useState<string | null>(null);
function toggleEditTagKey(key: string) {
@@ -390,41 +410,78 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
onSuccess: async () => {
await invalidateKeys(queryClient, moodDependentKeys);
await refetchInactiveDailyReads(queryClient);
setEditing(null);
setEditError(null);
setEditSeed({
mood: editMood,
tagsInput: editTagsInput,
tagKeys: [...editTagKeys].sort(),
ratedFactors: [...editRatedFactors].sort((a, b) =>
a.key.localeCompare(b.key),
),
note: editNote,
moodLoggedAt: editMoodLoggedAt,
});
dismissEdit();
},
onError: (err) => {
setEditError(err instanceof Error ? err.message : t("mood.saveError"));
setEditError(localizedApiError(err, t, "mood.saveError"));
},
});
const editDismissal = useSeededFormDismissal({
seed: editSeed,
value: {
mood: editMood,
tagsInput: editTagsInput,
tagKeys: [...editTagKeys].sort(),
ratedFactors: [...editRatedFactors].sort((a, b) =>
a.key.localeCompare(b.key),
),
note: editNote,
moodLoggedAt: editMoodLoggedAt,
},
blocked: updateMutation.isPending || deleteMutation.isPending,
});
const totalPages = data ? Math.ceil(data.meta.total / PAGE_SIZE) : 0;
function startEdit(entry: MoodEntry) {
const seed = {
mood: entry.mood,
tagsInput: entry.tags.join(", "),
tagKeys: [...(entry.tagKeys ?? [])].sort(),
ratedFactors: [...(entry.ratedFactors ?? [])].sort((a, b) =>
a.key.localeCompare(b.key),
),
note: entry.note ?? "",
moodLoggedAt: toDateTimeLocalValue(entry.moodLoggedAt),
};
setEditing(entry);
setEditMood(entry.mood);
setEditTagsInput(entry.tags.join(", "));
setEditTagKeys(entry.tagKeys ?? []);
setEditRatedFactors(entry.ratedFactors ?? []);
setEditNote(entry.note ?? "");
setEditMoodLoggedAt(toDateTimeLocalValue(entry.moodLoggedAt));
setEditMood(seed.mood);
setEditTagsInput(seed.tagsInput);
setEditTagKeys(seed.tagKeys);
setEditRatedFactors(seed.ratedFactors);
setEditNote(seed.note);
setEditMoodLoggedAt(seed.moodLoggedAt);
setEditSeed(seed);
setEditError(null);
}
function closeEdit() {
if (updateMutation.isPending || deleteMutation.isPending) return;
function dismissEdit() {
setEditing(null);
setEditError(null);
setEditDeleteDialogOpen(false);
}
function closeEdit() {
editDismissal.requestClose(dismissEdit);
}
async function deleteEditingEntry() {
if (!editing) return;
try {
setEditError(null);
await deleteMutation.mutateAsync(editing.id);
closeEdit();
dismissEdit();
} catch {
setEditError(t("mood.deleteError"));
}
@@ -1044,6 +1101,11 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
</form>
)}
</ResponsiveSheet>
<SeededFormDiscardDialog
open={editDismissal.discardDialogOpen}
onConfirm={editDismissal.confirmDiscard}
onCancel={editDismissal.cancelDiscard}
/>
</>
);
}
+2 -3
View File
@@ -20,6 +20,7 @@ import {
} from "@/components/ui/select";
import { useTranslations } from "@/lib/i18n/context";
import { apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { queryKeys } from "@/lib/query-keys";
import {
AnamnesisCard,
@@ -145,9 +146,7 @@ export function BaselineForm() {
await queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
router.push("/onboarding/4");
} catch (err) {
const message =
err instanceof Error ? err.message : t("onboarding.errorGeneric");
toast.error(message);
toast.error(localizedApiError(err, t, "onboarding.errorGeneric"));
setSaving(false);
}
}
@@ -19,6 +19,7 @@ import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { apiPost } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { queryKeys } from "@/lib/query-keys";
/**
@@ -131,9 +132,7 @@ export function GoalsChipPicker({ userId: _userId }: GoalsChipPickerProps) {
await queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
router.push("/onboarding/2");
} catch (err) {
const message =
err instanceof Error ? err.message : t("onboarding.errorGeneric");
toast.error(message);
toast.error(localizedApiError(err, t, "onboarding.errorGeneric"));
setAdvancing(false);
}
}
@@ -24,6 +24,7 @@ import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { apiPost } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { queryKeys } from "@/lib/query-keys";
/**
@@ -139,9 +140,7 @@ export function SourceCardGrid() {
await queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
router.push("/onboarding/3");
} catch (err) {
const message =
err instanceof Error ? err.message : t("onboarding.errorGeneric");
toast.error(message);
toast.error(localizedApiError(err, t, "onboarding.errorGeneric"));
setAdvancing(false);
}
}
@@ -22,6 +22,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { scrollBehaviorForUser } from "@/lib/motion";
import { cn } from "@/lib/utils";
import { apiPost } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { queryKeys } from "@/lib/query-keys";
import { DISCLAIMER_VERSION } from "@/lib/onboarding/disclaimer";
@@ -156,9 +157,7 @@ export function WelcomeCarousel() {
await queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
router.push("/onboarding/1");
} catch (err) {
const message =
err instanceof Error ? err.message : t("onboarding.errorGeneric");
toast.error(message);
toast.error(localizedApiError(err, t, "onboarding.errorGeneric"));
setAdvancing(false);
}
}
+16 -1
View File
@@ -28,7 +28,10 @@ import {
restorePersistedQueryCache,
startPersistingQueryCache,
} from "@/lib/pwa/query-persister";
import { QUERY_CLIENT_DEFAULT_OPTIONS } from "@/lib/pwa/query-client-options";
import {
QUERY_CLIENT_DEFAULT_OPTIONS,
subscribeToMeaningfulVisibilityRefresh,
} from "@/lib/pwa/query-client-options";
const SHELL_VERSION = process.env.NEXT_PUBLIC_APP_VERSION ?? "";
@@ -187,6 +190,17 @@ function DashboardSnapshotPreloader() {
return null;
}
// ── Meaningful PWA resume refresh ────────────────────
function QueryVisibilityRefreshBridge() {
const queryClient = useQueryClient();
useEffect(
() => subscribeToMeaningfulVisibilityRefresh(queryClient),
[queryClient],
);
return null;
}
// ── Offline query persistence ────────────────────────
//
// v1.18.6 — hydrate the last-synced query cache from IndexedDB before the
@@ -264,6 +278,7 @@ export function Providers({
<ThemeProvider>
<I18nProvider initialLocale={initialLocale}>
<QueryPersistenceBridge />
<QueryVisibilityRefreshBridge />
<DashboardSnapshotPreloader />
<OfflineMutationToaster />
{children}
@@ -0,0 +1,157 @@
import type { ReactElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
mutationOptions: [] as Array<{
mutationFn: (enabled: boolean) => Promise<void>;
onMutate?: (enabled: boolean) => Promise<unknown>;
onError?: (error: Error, enabled: boolean, previous: unknown) => void;
}>,
apiFetchRaw: vi.fn(),
queryClient: {
cancelQueries: vi.fn(),
getQueryData: vi.fn(),
setQueryData: vi.fn(),
invalidateQueries: vi.fn(),
},
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: ({ queryKey }: { queryKey: readonly string[] }) => {
const key = queryKey.join("/");
if (key === "settings/webhook") {
return {
data: {
enabled: false,
url: "https://saved.example/hook",
headerName: "Authorization",
hasHeaderValue: true,
},
};
}
if (key === "settings/ntfy") {
return {
data: {
enabled: false,
serverUrl: "https://ntfy.sh",
topic: "saved-topic",
hasAuthToken: true,
},
};
}
if (key === "settings/email") {
return {
data: {
enabled: false,
recipient: "saved@example.com",
smtpConfigured: true,
},
};
}
return {
data: { enabled: false, hasBotToken: true, chatId: "saved-chat" },
};
},
useMutation: (options: {
mutationFn: (enabled: boolean) => Promise<void>;
onMutate?: (enabled: boolean) => Promise<unknown>;
onError?: (error: Error, enabled: boolean, previous: unknown) => void;
}) => {
mocks.mutationOptions.push(options);
return { mutate: vi.fn(), isPending: false };
},
useQueryClient: () => mocks.queryClient,
}));
vi.mock("@/lib/api/api-fetch", () => ({
apiFetchRaw: mocks.apiFetchRaw,
apiGet: vi.fn(),
}));
vi.mock("../test-connection-button", () => ({
TestConnectionButton: () => null,
}));
import { I18nProvider } from "@/lib/i18n/context";
import { WebhookCard } from "../webhook-card";
import { NtfyCard } from "../ntfy-card";
import { EmailCard } from "../email-card";
import { TelegramCard } from "../telegram-card";
function render(card: ReactElement) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{card}</I18nProvider>,
);
}
beforeEach(() => {
vi.clearAllMocks();
mocks.mutationOptions.length = 0;
mocks.queryClient.cancelQueries.mockResolvedValue(undefined);
mocks.apiFetchRaw.mockResolvedValue(
new Response(JSON.stringify({ data: { saved: true }, error: null }), {
status: 200,
}),
);
});
describe("notification channel cards", () => {
it.each([
["webhook", <WebhookCard key="webhook" isAuthenticated />, 1],
["ntfy", <NtfyCard key="ntfy" isAuthenticated />, 1],
["email", <EmailCard key="email" isAuthenticated />, 1],
["telegram", <TelegramCard key="telegram" isAuthenticated />, 0],
] as const)(
"%s switch invokes the enable-only mutation rather than the full save",
async (channel, card, toggleMutationIndex) => {
render(card);
await mocks.mutationOptions[toggleMutationIndex]!.mutationFn(true);
expect(mocks.apiFetchRaw).toHaveBeenCalledTimes(1);
expect(mocks.apiFetchRaw).toHaveBeenCalledWith(
`/api/settings/${channel}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: true }),
},
);
},
);
it.each([
[<WebhookCard key="webhook" isAuthenticated />, 1, ["settings", "webhook"]],
[<NtfyCard key="ntfy" isAuthenticated />, 1, ["settings", "ntfy"]],
[<EmailCard key="email" isAuthenticated />, 1, ["settings", "email"]],
[
<TelegramCard key="telegram" isAuthenticated />,
0,
["telegram", "settings"],
],
] as const)(
"rolls each visible switch back to its previous server state on error",
async (card, toggleMutationIndex, queryKey) => {
const previous = { enabled: false, persistedConfig: "unchanged" };
mocks.queryClient.getQueryData.mockReturnValue(previous);
render(card);
const toggle = mocks.mutationOptions[toggleMutationIndex]!;
const context = await toggle.onMutate!(true);
const optimisticUpdater = mocks.queryClient.setQueryData.mock
.calls[0]![1] as (current: typeof previous) => typeof previous;
expect(optimisticUpdater(previous)).toEqual({
...previous,
enabled: true,
});
toggle.onError!(new Error("save failed"), true, context);
expect(mocks.queryClient.setQueryData).toHaveBeenLastCalledWith(
queryKey,
previous,
);
},
);
});
@@ -0,0 +1,93 @@
import { QueryClient } from "@tanstack/react-query";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/api/api-fetch", () => ({
apiFetchRaw: vi.fn(),
}));
import { apiFetchRaw } from "@/lib/api/api-fetch";
import {
optimisticallySetChannelEnabled,
persistChannelEnabled,
rollbackChannelEnabled,
} from "../notification-channel-toggle";
beforeEach(() => {
vi.clearAllMocks();
});
describe("notification channel enable-only mutation", () => {
it.each([
"/api/settings/webhook",
"/api/settings/ntfy",
"/api/settings/email",
"/api/settings/telegram",
])("sends only enabled to %s", async (endpoint) => {
vi.mocked(apiFetchRaw).mockResolvedValue(
new Response(JSON.stringify({ data: { saved: true } }), { status: 200 }),
);
await persistChannelEnabled(endpoint, true, "save failed");
expect(apiFetchRaw).toHaveBeenCalledWith(endpoint, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: true }),
});
expect(
JSON.parse(vi.mocked(apiFetchRaw).mock.calls[0]![1]!.body as string),
).toEqual({ enabled: true });
});
it("changes only cached enabled state and leaves configuration fields intact", () => {
const client = new QueryClient();
const key = ["settings", "webhook"] as const;
const settings = {
enabled: false,
url: "https://saved.example/hook",
topic: "saved-topic",
recipient: "saved@example.com",
hasHeaderValue: true,
};
client.setQueryData(key, settings);
const previous = optimisticallySetChannelEnabled(client, key, true);
expect(previous).toEqual(settings);
expect(client.getQueryData(key)).toEqual({ ...settings, enabled: true });
});
it("restores the exact previous cached settings after a failed toggle", () => {
const client = new QueryClient();
const key = ["settings", "telegram"] as const;
const previousSettings = {
enabled: false,
hasBotToken: true,
chatId: "123456",
};
client.setQueryData(key, previousSettings);
const previous = optimisticallySetChannelEnabled(client, key, true);
expect(client.getQueryData<{ enabled: boolean }>(key)?.enabled).toBe(true);
rollbackChannelEnabled(client, key, previous);
expect(client.getQueryData(key)).toEqual(previousSettings);
expect(client.getQueryData<{ enabled: boolean }>(key)?.enabled).toBe(false);
});
it("surfaces the route error so the card mutation can roll back", async () => {
vi.mocked(apiFetchRaw).mockResolvedValue(
new Response(
JSON.stringify({ error: "Stored configuration is missing" }),
{
status: 422,
},
),
);
await expect(
persistChannelEnabled("/api/settings/ntfy", true, "save failed"),
).rejects.toThrow("Stored configuration is missing");
});
});
@@ -1,11 +1,15 @@
"use client";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, Save, Shield, User } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import {
SeededFormDiscardDialog,
useSeededFormDismissal,
} from "@/components/forms/use-seeded-form-dismissal";
import { Input } from "@/components/ui/input";
import { DateField } from "@/components/ui/date-field";
import { Label } from "@/components/ui/label";
@@ -43,6 +47,10 @@ export function AccountSection() {
// settled (React #418 family).
const mounted = useMounted();
const router = useRouter();
const navigate = useCallback(
(target: string) => router.push(target),
[router],
);
const [email, setEmail] = useState("");
const [heightCm, setHeightCm] = useState("");
@@ -53,6 +61,16 @@ export function AccountSection() {
const [fullName, setFullName] = useState("");
const [insurerName, setInsurerName] = useState("");
const [insuranceNumber, setInsuranceNumber] = useState("");
const [profileSeed, setProfileSeed] = useState({
email: "",
heightCm: "",
dateOfBirth: "",
gender: "",
timezone: "Europe/Berlin",
fullName: "",
insurerName: "",
insuranceNumber: "",
});
const [saving, setSaving] = useState(false);
const [saveMsg, setSaveMsg] = useState<StatusMessage | null>(null);
const [saveMsgType, setSaveMsgType] = useState<"success" | "error" | null>(
@@ -88,26 +106,62 @@ export function AccountSection() {
// satisfying the strict `react-hooks/set-state-in-effect` rule.
const [seededUserId, setSeededUserId] = useState<string | null>(null);
if (user && user.id !== seededUserId) {
setSeededUserId(user.id);
setEmail(user.email ?? "");
setHeightCm(user.heightCm?.toString() ?? "");
setDateOfBirth(
user.dateOfBirth
const seed = {
email: user.email ?? "",
heightCm: user.heightCm?.toString() ?? "",
dateOfBirth: user.dateOfBirth
? new Date(user.dateOfBirth).toISOString().slice(0, 10)
: "",
);
setGender(user.gender ?? "");
setTimezone(resolveInitialTimezone(user.timezone, detectBrowserTimezone()));
setFullName(user.fullName ?? "");
setInsurerName(user.insurerName ?? "");
setInsuranceNumber(user.insuranceNumber ?? "");
gender: user.gender ?? "",
timezone: resolveInitialTimezone(user.timezone, detectBrowserTimezone()),
fullName: user.fullName ?? "",
insurerName: user.insurerName ?? "",
insuranceNumber: user.insuranceNumber ?? "",
};
setSeededUserId(user.id);
setEmail(seed.email);
setHeightCm(seed.heightCm);
setDateOfBirth(seed.dateOfBirth);
setGender(seed.gender);
setTimezone(seed.timezone);
setFullName(seed.fullName);
setInsurerName(seed.insurerName);
setInsuranceNumber(seed.insuranceNumber);
setProfileSeed(seed);
}
const profileDismissal = useSeededFormDismissal({
seed: profileSeed,
value: {
email,
heightCm,
dateOfBirth,
gender,
timezone,
fullName,
insurerName,
insuranceNumber,
},
blocked: saving,
guardNavigation: true,
navigate,
});
async function handleSaveProfile(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setSaveMsg(null);
setSaveMsgType(null);
const savedProfile = {
email: email.trim(),
heightCm,
dateOfBirth,
gender,
timezone,
fullName: fullName.trim(),
insurerName: insurerName.trim(),
insuranceNumber: insuranceNumber.trim(),
};
// The timezone is owned by a dedicated route (v1.4.25 W7) so the
// resolver cache can be invalidated without piping the flag
@@ -118,13 +172,15 @@ export function AccountSection() {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: email.trim() || null,
heightCm: heightCm ? parseFloat(heightCm) : null,
dateOfBirth: dateOfBirth || null,
gender: gender || null,
fullName: fullName.trim() || null,
insurerName: insurerName.trim() || null,
insuranceNumber: insuranceNumber.trim() || null,
email: savedProfile.email || null,
heightCm: savedProfile.heightCm
? parseFloat(savedProfile.heightCm)
: null,
dateOfBirth: savedProfile.dateOfBirth || null,
gender: savedProfile.gender || null,
fullName: savedProfile.fullName || null,
insurerName: savedProfile.insurerName || null,
insuranceNumber: savedProfile.insuranceNumber || null,
}),
}),
user && timezone && timezone !== user.timezone
@@ -146,6 +202,11 @@ export function AccountSection() {
}
setSaveMsg({ key: "settings.profileSaved" });
setSaveMsgType("success");
setEmail(savedProfile.email);
setFullName(savedProfile.fullName);
setInsurerName(savedProfile.insurerName);
setInsuranceNumber(savedProfile.insuranceNumber);
setProfileSeed(savedProfile);
await refetch();
} else if (!tzRes.ok) {
// The dedicated tz endpoint owns the IANA validation error
@@ -579,6 +640,11 @@ export function AccountSection() {
</Button>
</form>
</ResponsiveSheet>
<SeededFormDiscardDialog
open={profileDismissal.discardDialogOpen}
onConfirm={profileDismissal.confirmDiscard}
onCancel={profileDismissal.cancelDiscard}
/>
</div>
);
}
@@ -4,7 +4,7 @@
* Anthropic form — API key + model dropdown.
* ──────────────────────────────────────────────────────────────── */
import { useState } from "react";
import { useRef, useState, type FormEvent } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Loader2, Save } from "lucide-react";
@@ -37,6 +37,7 @@ export function AnthropicProviderForm({
const [customModel, setCustomModel] = useState("");
const [msg, setMsg] = useState<string | null>(null);
const [ok, setOk] = useState(false);
const submitInFlightRef = useRef(false);
const seededKey =
userProvider != null
@@ -85,10 +86,25 @@ export function AnthropicProviderForm({
setOk(false);
setMsg(e instanceof Error ? e.message : t("settings.ai.errorGeneric"));
},
onSettled: () => {
submitInFlightRef.current = false;
},
});
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (submitInFlightRef.current || saveMutation.isPending) return;
submitInFlightRef.current = true;
saveMutation.mutate();
}
return (
<div data-testid="ai-provider-config-anthropic" className="space-y-4">
<form
data-testid="ai-provider-config-anthropic"
className="space-y-4"
onSubmit={submit}
noValidate
>
<div>
<Label htmlFor="ai-anthropic-key">
{t("settings.ai.anthropicKeyLabel")}
@@ -148,9 +164,10 @@ export function AnthropicProviderForm({
<div>
<Button
type="submit"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => saveMutation.mutate()}
aria-busy={saveMutation.isPending || undefined}
disabled={saveMutation.isPending}
>
{saveMutation.isPending ? (
@@ -167,6 +184,6 @@ export function AnthropicProviderForm({
{msg}
</p>
)}
</div>
</form>
);
}
@@ -4,7 +4,7 @@
* Local (OpenAI-compatible) form — base URL + optional key + model.
* ──────────────────────────────────────────────────────────────── */
import { useState } from "react";
import { useRef, useState, type FormEvent } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Loader2, Save } from "lucide-react";
@@ -38,6 +38,7 @@ export function LocalProviderForm({
const [customModel, setCustomModel] = useState("");
const [msg, setMsg] = useState<string | null>(null);
const [ok, setOk] = useState(false);
const submitInFlightRef = useRef(false);
const seededKey =
userProvider != null
@@ -85,10 +86,25 @@ export function LocalProviderForm({
setOk(false);
setMsg(e instanceof Error ? e.message : t("settings.ai.errorGeneric"));
},
onSettled: () => {
submitInFlightRef.current = false;
},
});
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (submitInFlightRef.current || saveMutation.isPending) return;
submitInFlightRef.current = true;
saveMutation.mutate();
}
return (
<div data-testid="ai-provider-config-local" className="space-y-4">
<form
data-testid="ai-provider-config-local"
className="space-y-4"
onSubmit={submit}
noValidate
>
{/* v1.28.28 (#470) — name the gateway path explicitly. The Local
provider is the ONE user-level custom-URL provider, so it is also
the documented way to reach LiteLLM / OpenRouter / vLLM and any
@@ -156,9 +172,10 @@ export function LocalProviderForm({
<div>
<Button
type="submit"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => saveMutation.mutate()}
aria-busy={saveMutation.isPending || undefined}
disabled={saveMutation.isPending}
>
{saveMutation.isPending ? (
@@ -175,6 +192,6 @@ export function LocalProviderForm({
{msg}
</p>
)}
</div>
</form>
);
}
@@ -7,7 +7,7 @@
* pick is visible to every code path that reads the row.
* ──────────────────────────────────────────────────────────────── */
import { useState } from "react";
import { useRef, useState, type FormEvent } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Loader2, Save } from "lucide-react";
@@ -42,6 +42,7 @@ export function OpenAIProviderForm({
const [showAdvanced, setShowAdvanced] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [ok, setOk] = useState(false);
const submitInFlightRef = useRef(false);
const seededKey =
userProvider != null
@@ -92,10 +93,25 @@ export function OpenAIProviderForm({
setOk(false);
setMsg(e instanceof Error ? e.message : t("settings.ai.errorGeneric"));
},
onSettled: () => {
submitInFlightRef.current = false;
},
});
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (submitInFlightRef.current || saveMutation.isPending) return;
submitInFlightRef.current = true;
saveMutation.mutate();
}
return (
<div data-testid="ai-provider-config-openai" className="space-y-4">
<form
data-testid="ai-provider-config-openai"
className="space-y-4"
onSubmit={submit}
noValidate
>
<div>
<Label htmlFor="ai-openai-key">
{t("settings.ai.openai.apiKey")}
@@ -185,9 +201,10 @@ export function OpenAIProviderForm({
<div className="flex flex-wrap gap-2">
<Button
type="submit"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => saveMutation.mutate()}
aria-busy={saveMutation.isPending || undefined}
disabled={saveMutation.isPending}
>
{saveMutation.isPending ? (
@@ -204,6 +221,6 @@ export function OpenAIProviderForm({
{msg}
</p>
)}
</div>
</form>
);
}
@@ -11,7 +11,7 @@
* (~120 s, `AI_BUDGETS.comprehensive.timeoutMs`).
* ──────────────────────────────────────────────────────────────── */
import { useState } from "react";
import { useRef, useState, type FormEvent } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Loader2, Save } from "lucide-react";
@@ -35,6 +35,7 @@ export function ResponseTimeoutCard({
const [value, setValue] = useState("");
const [msg, setMsg] = useState<string | null>(null);
const [ok, setOk] = useState(false);
const submitInFlightRef = useRef(false);
// Seed from the persisted value once it arrives (seed-on-data pattern).
const seededKey =
@@ -74,10 +75,24 @@ export function ResponseTimeoutCard({
setOk(false);
setMsg(e instanceof Error ? e.message : t("settings.ai.errorGeneric"));
},
onSettled: () => {
submitInFlightRef.current = false;
},
});
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (submitInFlightRef.current || saveMutation.isPending) return;
submitInFlightRef.current = true;
saveMutation.mutate();
}
return (
<div className="bg-muted/50 space-y-3 rounded-lg p-4">
<form
className="bg-muted/50 space-y-3 rounded-lg p-4"
onSubmit={submit}
noValidate
>
<div>
<p className="text-sm font-medium">
{t("settings.ai.responseTimeoutHeading")}
@@ -104,9 +119,10 @@ export function ResponseTimeoutCard({
</div>
<div>
<Button
type="submit"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => saveMutation.mutate()}
aria-busy={saveMutation.isPending || undefined}
disabled={saveMutation.isPending}
>
{saveMutation.isPending ? (
@@ -122,6 +138,6 @@ export function ResponseTimeoutCard({
{msg}
</p>
)}
</div>
</form>
);
}
@@ -202,6 +202,7 @@ export function RuntimeActionsRow({
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
size="sm"
variant="outline"
className="min-h-11 sm:min-h-9"
+41 -3
View File
@@ -11,6 +11,11 @@ import { Switch } from "@/components/ui/switch";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import {
optimisticallySetChannelEnabled,
persistChannelEnabled,
rollbackChannelEnabled,
} from "@/components/settings/notification-channel-toggle";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
@@ -70,6 +75,39 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) {
},
});
const toggleEnabled = useMutation<
void,
Error,
boolean,
EmailSettings | undefined
>({
mutationFn: (enabled) =>
persistChannelEnabled("/api/settings/email", enabled, t("common.error")),
onMutate: async (enabled) => {
const queryKey = queryKeys.settingsEmail();
await queryClient.cancelQueries({ queryKey });
setSaveMsg(null);
setSaveMsgType(null);
return optimisticallySetChannelEnabled<EmailSettings>(
queryClient,
queryKey,
enabled,
);
},
onSuccess: () => {
setSaveMsg(t("settings.saved"));
setSaveMsgType("success");
},
onError: (err, _enabled, previous) => {
rollbackChannelEnabled(queryClient, queryKeys.settingsEmail(), previous);
setSaveMsg(err.message);
setSaveMsgType("error");
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.settingsEmail() });
},
});
// Operator hasn't configured SMTP — no transport, so no card. The dispatcher
// skips the channel too; surfacing an unusable toggle would only confuse.
if (settings && !settings.smtpConfigured) return null;
@@ -88,8 +126,8 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) {
<Switch
id="email-toggle"
checked={settings?.enabled ?? false}
onCheckedChange={(checked) => save.mutate(checked)}
disabled={save.isPending}
onCheckedChange={(checked) => toggleEnabled.mutate(checked)}
disabled={save.isPending || toggleEnabled.isPending}
/>
</div>
@@ -131,7 +169,7 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) {
/>
<Button
type="submit"
disabled={save.isPending}
disabled={save.isPending || toggleEnabled.isPending}
className="min-h-11"
>
{save.isPending && (
+1 -1
View File
@@ -56,7 +56,7 @@ type ExportFormat = "CSV" | "JSON" | "FHIR";
* one place.
*/
async function downloadFromUrl(url: string, filename: string): Promise<void> {
const res = await fetch(url, { credentials: "include" });
const res = await apiFetchRaw(url, { credentials: "include" });
if (!res.ok) {
throw new Error(`Download failed (${res.status})`);
}
@@ -0,0 +1,155 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import type { Locale } from "@/lib/i18n/config";
import { I18nProvider } from "@/lib/i18n/context";
import de from "../../../../../messages/de.json";
import en from "../../../../../messages/en.json";
import es from "../../../../../messages/es.json";
import fr from "../../../../../messages/fr.json";
import itMessages from "../../../../../messages/it.json";
import pl from "../../../../../messages/pl.json";
let statusPayload: { lastSyncedAt: string | null } | undefined;
let statusLoading = false;
let statusError = false;
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
data: statusPayload,
isLoading: statusLoading,
isError: statusError,
}),
}));
import { AppleHealthCard } from "../apple-health-card";
function render(locale: Locale = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<AppleHealthCard enabled />
</I18nProvider>,
);
}
describe("<AppleHealthCard>", () => {
beforeEach(() => {
statusPayload = undefined;
statusLoading = false;
statusError = false;
});
it("explains that live sync uses the iOS app rather than OAuth", () => {
statusPayload = { lastSyncedAt: null };
statusLoading = false;
const html = render();
expect(html).toContain('data-testid="apple-health-card"');
expect(html).toContain("HealthLog iOS app");
expect(html).toContain("not a web or OAuth connection");
expect(html).toContain("Settings → Apple Health");
expect(html).toContain("when iOS grants background time");
expect(html).not.toContain(">Connected<");
});
it("keeps the status neutral when no Apple Health data has been observed", () => {
statusPayload = { lastSyncedAt: null };
statusLoading = false;
const html = render();
expect(html).toContain('data-testid="apple-health-status"');
expect(html).toContain('data-state="setup"');
expect(html).toContain("Set up in iOS app");
expect(html).not.toContain('data-state="connected"');
});
it("reports only observed data freshness without claiming a connection", () => {
statusPayload = { lastSyncedAt: "2026-07-20T10:30:00.000Z" };
statusLoading = false;
const html = render();
expect(html).toContain('data-state="recent-data"');
expect(html).toContain("Apple Health data received");
expect(html).toContain("Last Apple Health data:");
expect(html).toContain('dateTime="2026-07-20T10:30:00.000Z"');
expect(html).not.toContain(">Connected<");
expect(html).not.toContain('data-state="connected"');
});
it("links to the existing one-shot Apple Health import fallback", () => {
statusPayload = { lastSyncedAt: null };
statusLoading = false;
const html = render();
expect(html).toContain('data-testid="apple-health-import-link"');
expect(html).toContain(
'href="/settings/export#settings-section-import-title"',
);
expect(html).toContain("Open one-shot import");
});
it("uses a non-committal checking state while status is loading", () => {
statusPayload = undefined;
statusLoading = true;
const html = render();
expect(html).toContain('data-state="checking"');
expect(html).toContain("Checking Apple Health data…");
expect(html).not.toContain("Connected");
});
it("does not turn a failed status read into a setup or connection claim", () => {
statusError = true;
const html = render();
expect(html).toContain('data-state="unavailable"');
expect(html).toContain("Apple Health status unavailable");
expect(html).not.toContain('data-state="setup"');
expect(html).not.toContain("Connected");
});
it("defines every card key in all six locale catalogs", () => {
const requiredKeys = [
"title",
"description",
"lastDataLabel",
"setupTitle",
"permissionStep",
"backgroundStep",
"importNote",
"importAction",
] as const;
for (const catalog of [en, de, es, fr, itMessages, pl]) {
const appleHealth = catalog.settings.appleHealth;
for (const key of requiredKeys) {
expect(appleHealth[key]).toBeTypeOf("string");
expect(appleHealth[key].length).toBeGreaterThan(0);
}
expect(Object.keys(appleHealth.status).sort()).toEqual([
"checking",
"dataReceived",
"setup",
"unavailable",
]);
}
});
it.each(["en", "de", "es", "fr", "it", "pl"] as const)(
"renders localized Apple Health copy for %s without leaking keys",
(locale) => {
statusPayload = { lastSyncedAt: null };
statusLoading = false;
const html = render(locale);
expect(html).toContain("Apple Health");
expect(html).not.toContain("settings.appleHealth.");
},
);
});
@@ -0,0 +1,123 @@
"use client";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { CheckCircle2, HeartPulse, Info, Upload } from "lucide-react";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { SettingsCard } from "@/components/settings/settings-card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { apiGet } from "@/lib/api/api-fetch";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
interface HealthKitStatus {
lastSyncedAt: string | null;
}
export function AppleHealthCard({ enabled }: { enabled: boolean }) {
const { t } = useTranslations();
const format = useFormatters();
const {
data: status,
isLoading,
isError,
} = useQuery({
queryKey: queryKeys.healthKitStatus(),
queryFn: () => apiGet<HealthKitStatus>("/api/integrations/healthkit"),
enabled,
refetchOnWindowFocus: true,
});
const lastSyncedAt = status?.lastSyncedAt ?? null;
const statusState = isLoading
? "checking"
: isError
? "unavailable"
: lastSyncedAt
? "recent-data"
: "setup";
const statusLabel = isLoading
? t("settings.appleHealth.status.checking")
: isError
? t("settings.appleHealth.status.unavailable")
: lastSyncedAt
? t("settings.appleHealth.status.dataReceived")
: t("settings.appleHealth.status.setup");
return (
<SettingsCard
as="section"
aria-labelledby="apple-health-card-title"
data-testid="apple-health-card"
className="space-y-4"
>
<SettingsCardHeader
icon={HeartPulse}
titleId="apple-health-card-title"
title={t("settings.appleHealth.title")}
description={t("settings.appleHealth.description")}
status={
<Badge
variant={lastSyncedAt ? undefined : "outline"}
data-testid="apple-health-status"
data-state={statusState}
className={
lastSyncedAt
? "border-success/30 bg-success/15 text-success max-w-full whitespace-nowrap"
: "max-w-full whitespace-nowrap"
}
>
{lastSyncedAt ? (
<CheckCircle2 className="h-3 w-3" aria-hidden="true" />
) : (
<Info className="h-3 w-3" aria-hidden="true" />
)}
<span className="truncate">{statusLabel}</span>
</Badge>
}
/>
{lastSyncedAt && (
<p
className="text-muted-foreground text-xs"
data-testid="apple-health-last-data"
>
{t("settings.appleHealth.lastDataLabel")}{" "}
<time dateTime={lastSyncedAt}>{format.dateTime(lastSyncedAt)}</time>
</p>
)}
<div className="space-y-2">
<h3 className="text-sm font-semibold">
{t("settings.appleHealth.setupTitle")}
</h3>
<ol className="text-muted-foreground list-decimal space-y-2 pl-5 text-sm">
<li>{t("settings.appleHealth.permissionStep")}</li>
<li>{t("settings.appleHealth.backgroundStep")}</li>
</ol>
</div>
<div className="border-border bg-muted/30 flex flex-col gap-3 rounded-lg border p-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-muted-foreground text-xs sm:max-w-xl">
{t("settings.appleHealth.importNote")}
</p>
<Button
asChild
variant="outline"
size="sm"
className="min-h-11 shrink-0 sm:min-h-9"
>
<Link
href="/settings/export#settings-section-import-title"
data-testid="apple-health-import-link"
>
<Upload className="h-3.5 w-3.5" aria-hidden="true" />
{t("settings.appleHealth.importAction")}
</Link>
</Button>
</div>
</SettingsCard>
);
}
@@ -16,6 +16,7 @@ import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { AppleHealthCard } from "@/components/settings/integrations/apple-health-card";
import { FitbitCard } from "@/components/settings/integrations/fitbit-card";
import { GarminInfoNote } from "@/components/settings/integrations/garmin-info-note";
import { GoogleHealthCard } from "@/components/settings/integrations/google-health-card";
@@ -285,6 +286,9 @@ export function ConnectionsPanel() {
<div id="google-health" className="scroll-mt-28">
<GoogleHealthCard viewModel={googleHealthViewModel} />
</div>
<div id="apple-health" className="scroll-mt-28">
<AppleHealthCard enabled={isAuthenticated} />
</div>
<div id="polar" className="scroll-mt-28">
<PolarCard enabled={isAuthenticated} viewModel={polarViewModel} />
</div>
@@ -0,0 +1,44 @@
import type { QueryClient, QueryKey } from "@tanstack/react-query";
import { apiFetchRaw } from "@/lib/api/api-fetch";
interface EnabledSettings {
enabled: boolean;
}
export async function persistChannelEnabled(
endpoint: string,
enabled: boolean,
fallbackError: string,
): Promise<void> {
const response = await apiFetchRaw(endpoint, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled }),
});
if (!response.ok) {
const json = (await response.json()) as { error?: string };
throw new Error(json.error || fallbackError);
}
}
export function optimisticallySetChannelEnabled<T extends EnabledSettings>(
queryClient: QueryClient,
queryKey: QueryKey,
enabled: boolean,
): T | undefined {
const previous = queryClient.getQueryData<T>(queryKey);
queryClient.setQueryData<T>(queryKey, (current) =>
current ? { ...current, enabled } : current,
);
return previous;
}
export function rollbackChannelEnabled<T extends EnabledSettings>(
queryClient: QueryClient,
queryKey: QueryKey,
previous: T | undefined,
): void {
queryClient.setQueryData(queryKey, previous);
}
@@ -37,7 +37,7 @@ import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet, apiPost } from "@/lib/api/api-fetch";
import { apiFetchRaw, apiGet, apiPost } from "@/lib/api/api-fetch";
type ChannelType = "TELEGRAM" | "NTFY" | "WEB_PUSH" | "APNS";
@@ -97,7 +97,9 @@ export function NotificationStatusCard() {
const test = useMutation({
mutationFn: async (channel: ChannelStatus) => {
const res = await fetch(TEST_ENDPOINTS[channel.type], { method: "POST" });
const res = await apiFetchRaw(TEST_ENDPOINTS[channel.type], {
method: "POST",
});
if (!res.ok) {
const json = await res.json().catch(() => ({}));
throw new Error((json as { error?: string }).error ?? "test_failed");
+41 -3
View File
@@ -12,6 +12,11 @@ import { PasswordInput } from "@/components/ui/password-input";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import {
optimisticallySetChannelEnabled,
persistChannelEnabled,
rollbackChannelEnabled,
} from "@/components/settings/notification-channel-toggle";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
@@ -82,6 +87,39 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
},
});
const toggleEnabled = useMutation<
void,
Error,
boolean,
NtfySettings | undefined
>({
mutationFn: (enabled) =>
persistChannelEnabled("/api/settings/ntfy", enabled, t("common.error")),
onMutate: async (enabled) => {
const queryKey = queryKeys.settingsNtfy();
await queryClient.cancelQueries({ queryKey });
setSaveMsg(null);
setSaveMsgType(null);
return optimisticallySetChannelEnabled<NtfySettings>(
queryClient,
queryKey,
enabled,
);
},
onSuccess: () => {
setSaveMsg(t("settings.saved"));
setSaveMsgType("success");
},
onError: (err, _enabled, previous) => {
rollbackChannelEnabled(queryClient, queryKeys.settingsNtfy(), previous);
setSaveMsg(err.message);
setSaveMsgType("error");
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.settingsNtfy() });
},
});
return (
<SettingsCard>
<SettingsCardHeader
@@ -96,8 +134,8 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
<Switch
id="ntfy-toggle"
checked={settings?.enabled ?? false}
onCheckedChange={(checked) => save.mutate(checked)}
disabled={save.isPending}
onCheckedChange={(checked) => toggleEnabled.mutate(checked)}
disabled={save.isPending || toggleEnabled.isPending}
/>
</div>
@@ -161,7 +199,7 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
/>
<Button
type="submit"
disabled={save.isPending}
disabled={save.isPending || toggleEnabled.isPending}
className="min-h-11"
>
{save.isPending && (
+59 -10
View File
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2, MessageCircle, Save } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -12,6 +12,11 @@ import { PasswordInput } from "@/components/ui/password-input";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import {
optimisticallySetChannelEnabled,
persistChannelEnabled,
rollbackChannelEnabled,
} from "@/components/settings/notification-channel-toggle";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
@@ -31,7 +36,6 @@ export function TelegramCard({
const queryClient = useQueryClient();
const [botToken, setBotToken] = useState("");
const [chatId, setChatId] = useState("");
const [enabled, setEnabled] = useState(false);
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
@@ -48,23 +52,63 @@ export function TelegramCard({
// pattern instead of setState-in-effect. This avoids the strict
// `react-hooks/set-state-in-effect` lint failure that the legacy file
// disabled at module scope.
const settingsKey = settings
? `${settings.enabled}|${settings.chatId ?? ""}`
: null;
const settingsKey = settings ? (settings.chatId ?? "") : null;
const [seededKey, setSeededKey] = useState<string | null>(null);
if (settingsKey && settingsKey !== seededKey) {
setSeededKey(settingsKey);
setEnabled(settings!.enabled);
if (settings!.chatId) setChatId(settings!.chatId);
}
const toggleEnabled = useMutation<
void,
Error,
boolean,
TelegramSettings | undefined
>({
mutationFn: (enabled) =>
persistChannelEnabled(
"/api/settings/telegram",
enabled,
t("settings.savingError"),
),
onMutate: async (enabled) => {
const queryKey = queryKeys.telegramSettings();
await queryClient.cancelQueries({ queryKey });
setMsg(null);
setMsgType(null);
return optimisticallySetChannelEnabled<TelegramSettings>(
queryClient,
queryKey,
enabled,
);
},
onSuccess: () => {
setMsg(t("settings.saved"));
setMsgType("success");
},
onError: (err, _enabled, previous) => {
rollbackChannelEnabled(
queryClient,
queryKeys.telegramSettings(),
previous,
);
setMsg(err.message);
setMsgType("error");
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.telegramSettings() });
},
});
async function handleSave(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setMsg(null);
setMsgType(null);
const body: Record<string, unknown> = { enabled };
const body: Record<string, unknown> = {
enabled: settings?.enabled ?? false,
};
if (botToken.trim()) body.botToken = botToken.trim();
if (chatId !== (settings?.chatId ?? "")) body.chatId = chatId;
@@ -137,8 +181,9 @@ export function TelegramCard({
<div className="flex items-center gap-3">
<Switch
id="tg-enabled"
checked={enabled}
onCheckedChange={setEnabled}
checked={settings?.enabled ?? false}
onCheckedChange={(checked) => toggleEnabled.mutate(checked)}
disabled={saving || toggleEnabled.isPending}
/>
<Label htmlFor="tg-enabled" className="cursor-pointer">
{t("settings.enableNotifications")}
@@ -167,7 +212,11 @@ export function TelegramCard({
endpoint="/api/settings/telegram/test"
disabled={!settings?.hasBotToken}
/>
<Button type="submit" disabled={saving} className="min-h-11">
<Button
type="submit"
disabled={saving || toggleEnabled.isPending}
className="min-h-11"
>
{saving ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
@@ -5,6 +5,7 @@ import { CheckCircle2, Loader2, Plug, XCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { apiFetchRaw } from "@/lib/api/api-fetch";
/**
* Shared "Test connection" UX for the settings integrations + notifications
@@ -46,7 +47,7 @@ export function TestConnectionButton({
setTesting(true);
setResult(null);
try {
const res = await fetch(endpoint, { method: "POST" });
const res = await apiFetchRaw(endpoint, { method: "POST" });
const json = (await res.json().catch(() => ({}))) as TestResponse;
if (res.ok && json.data?.ok !== false) {
@@ -103,7 +103,7 @@ export function ThresholdsEditorSection({ id }: { id: string }) {
const url = metric
? `/api/user/thresholds?metric=${metric}`
: "/api/user/thresholds";
const res = await fetch(url, { method: "DELETE" });
const res = await apiFetchRaw(url, { method: "DELETE" });
if (!res.ok) throw new Error("reset failed");
},
onSuccess: () => {
+49 -3
View File
@@ -12,6 +12,11 @@ import { PasswordInput } from "@/components/ui/password-input";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import {
optimisticallySetChannelEnabled,
persistChannelEnabled,
rollbackChannelEnabled,
} from "@/components/settings/notification-channel-toggle";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
@@ -82,6 +87,47 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
},
});
const toggleEnabled = useMutation<
void,
Error,
boolean,
WebhookSettings | undefined
>({
mutationFn: (enabled) =>
persistChannelEnabled(
"/api/settings/webhook",
enabled,
t("common.error"),
),
onMutate: async (enabled) => {
const queryKey = queryKeys.settingsWebhook();
await queryClient.cancelQueries({ queryKey });
setSaveMsg(null);
setSaveMsgType(null);
return optimisticallySetChannelEnabled<WebhookSettings>(
queryClient,
queryKey,
enabled,
);
},
onSuccess: () => {
setSaveMsg(t("settings.saved"));
setSaveMsgType("success");
},
onError: (err, _enabled, previous) => {
rollbackChannelEnabled(
queryClient,
queryKeys.settingsWebhook(),
previous,
);
setSaveMsg(err.message);
setSaveMsgType("error");
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.settingsWebhook() });
},
});
return (
<SettingsCard>
<SettingsCardHeader
@@ -96,8 +142,8 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
<Switch
id="webhook-toggle"
checked={settings?.enabled ?? false}
onCheckedChange={(checked) => save.mutate(checked)}
disabled={save.isPending}
onCheckedChange={(checked) => toggleEnabled.mutate(checked)}
disabled={save.isPending || toggleEnabled.isPending}
/>
</div>
@@ -165,7 +211,7 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
/>
<Button
type="submit"
disabled={save.isPending}
disabled={save.isPending || toggleEnabled.isPending}
className="min-h-11"
>
{save.isPending && (