mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
release: v1.32.0 — hardening and scalability (#580)
* release: prepare v1.32.0 * test: stabilize water capture re-entry
This commit is contained in:
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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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")}
|
||||
>
|
||||
|
||||
@@ -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&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&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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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\}/,
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in new issue
Block a user