feat(settings): research-mode toggle with version re-prompt banner

Adds the Research Mode card to Settings → Advanced alongside the
existing danger zone. Toggle ON opens the acknowledgment dialog (the
dialog owns the POST so the user reads the disclaimer first); OFF
fires DELETE directly. An amber re-prompt banner appears above the
toggle whenever the server's currentDisclaimerVersion drifts ahead of
the acknowledgedVersion, surfacing a Re-acknowledge CTA that re-opens
the dialog without flipping the toggle. The Advanced section
description is updated across all six locales to reflect that the
section now hosts both controls.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 20:30:51 +02:00
1 parent 85ba382009
commit 20a72d2e32
8 files changed
+444 -12

No files matched your search

@@ -0,0 +1,191 @@
/**
* v1.4.25 W19c-Frontend — `<AdvancedSection>` Research Mode card tests.
*
* The card lives next to the danger-zone delete and drives three
* server interactions:
*
* GET /api/auth/me/research-mode on mount, via TanStack Query
* POST /api/auth/me/research-mode via the acknowledgment dialog
* DELETE /api/auth/me/research-mode directly when the user
* toggles OFF
*
* The toggle ON path opens the dialog; only the dialog's CTA fires
* the POST (so the user reads the disclaimer first). OFF fires
* DELETE directly. A version-mismatch banner renders above the
* toggle whenever `acknowledgedVersion !== currentDisclaimerVersion`
* even when `enabled === true`.
*
* The dialog itself is covered by
* `ResearchModeAcknowledgmentDialog.test.tsx`; we mock it down to a
* marker here so the toggle behaviour is the only contract under
* test.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
const queryResults: Record<string, unknown> = {};
function setQueryResult(keyJoined: string, data: unknown) {
queryResults[keyJoined] = data;
}
vi.mock("@tanstack/react-query", () => ({
useQuery: ({ queryKey }: { queryKey: unknown[] }) => {
const key = (queryKey as Array<string | number>).join("/");
return {
data: queryResults[key],
isLoading: false,
};
},
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
}));
// The acknowledgment dialog is exercised in its own spec; mock the
// import to a transparent marker so we can assert "the toggle ON path
// renders the dialog with the right props" without re-running the
// dialog tree.
vi.mock("@/components/medications/ResearchModeAcknowledgmentDialog", () => ({
ResearchModeAcknowledgmentDialog: ({
open,
currentDisclaimerVersion,
}: {
open: boolean;
currentDisclaimerVersion: string | null;
}) => (
<div
data-slot="mock-ack-dialog"
data-open={open ? "true" : "false"}
data-version={currentDisclaimerVersion ?? ""}
/>
),
}));
import { AdvancedSection } from "../advanced-section";
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
beforeEach(() => {
for (const k of Object.keys(queryResults)) delete queryResults[k];
});
describe("<AdvancedSection> → Research Mode card", () => {
it("renders the section title + subtitle + toggle in OFF state", () => {
setQueryResult("research-mode", {
enabled: false,
acknowledgedAt: null,
acknowledgedVersion: null,
currentDisclaimerVersion: "2026-05-14.1",
});
const html = render(<AdvancedSection />);
expect(html).toContain('data-slot="settings-research-mode-card"');
expect(html).toContain("Research Mode");
expect(html).toContain("Show the estimated drug-level chart");
// OFF state status line.
expect(html).toContain("Disabled. The drug-level chart is hidden.");
// No re-prompt banner when versions already align (or the user has
// never acknowledged in the first place).
expect(html).not.toContain('data-slot="settings-research-mode-reprompt"');
// Switch is unchecked.
const switchTag = html.match(
/<button[^>]*data-slot="settings-research-mode-toggle"[^>]*>/,
)?.[0];
expect(switchTag).toBeDefined();
expect(switchTag).not.toMatch(/data-state="checked"/);
});
it("renders the acknowledged-on status when enabled + versions aligned", () => {
setQueryResult("research-mode", {
enabled: true,
acknowledgedAt: "2026-05-14T08:30:00Z",
acknowledgedVersion: "2026-05-14.1",
currentDisclaimerVersion: "2026-05-14.1",
});
const html = render(<AdvancedSection />);
// Acknowledged-on copy carries the formatted date (the i18n
// template uses `{date}`; the formatter renders the day).
expect(html).toMatch(/Acknowledged on/);
// Banner stays hidden when versions match.
expect(html).not.toContain('data-slot="settings-research-mode-reprompt"');
// Switch is checked.
const switchTag = html.match(
/<button[^>]*data-slot="settings-research-mode-toggle"[^>]*>/,
)?.[0];
expect(switchTag).toMatch(/data-state="checked"/);
});
it("renders the re-prompt banner when versions diverge", () => {
setQueryResult("research-mode", {
enabled: true,
acknowledgedAt: "2026-04-01T00:00:00Z",
acknowledgedVersion: "2026-04-01.1",
currentDisclaimerVersion: "2026-05-14.1",
});
const html = render(<AdvancedSection />);
expect(html).toContain('data-slot="settings-research-mode-reprompt"');
expect(html).toContain("Disclaimer updated");
expect(html).toContain(
'data-slot="settings-research-mode-reprompt-cta"',
);
expect(html).toContain("Re-acknowledge disclaimer");
// Status line reflects the stale state without claiming
// "acknowledged on …" — that copy would mis-represent that the
// chart is currently painting.
expect(html).not.toMatch(/Acknowledged on/);
expect(html).toContain(
"The disclaimer was updated. Re-acknowledge below to bring the chart back.",
);
// The toggle stays "on" — we surface the previous choice; the
// banner explains why the chart isn't painting.
const switchTag = html.match(
/<button[^>]*data-slot="settings-research-mode-toggle"[^>]*>/,
)?.[0];
expect(switchTag).toMatch(/data-state="checked"/);
});
it("forwards the current server-supplied version to the dialog", () => {
setQueryResult("research-mode", {
enabled: false,
acknowledgedAt: null,
acknowledgedVersion: null,
currentDisclaimerVersion: "2026-06-01.2",
});
const html = render(<AdvancedSection />);
// The dialog renders even when closed (its `open` prop is what
// controls visibility); the data-version attribute lets us verify
// the parent passes the live version string through.
expect(html).toContain('data-slot="mock-ack-dialog"');
expect(html).toMatch(/data-version="2026-06-01\.2"/);
expect(html).toMatch(/data-open="false"/);
});
it("renders German copy when locale='de'", () => {
setQueryResult("research-mode", {
enabled: false,
acknowledgedAt: null,
acknowledgedVersion: null,
currentDisclaimerVersion: "2026-05-14.1",
});
const html = render(<AdvancedSection />, "de");
expect(html).toContain("Forschungsmodus");
expect(html).toContain("Geschätzte Wirkstoffkurve anzeigen");
expect(html).toContain("Deaktiviert");
});
});
+171 -2
View File
@@ -1,8 +1,8 @@
"use client";
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, Loader2, Trash2 } from "lucide-react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, BookOpenCheck, Loader2, Trash2 } from "lucide-react";
import {
AlertDialog,
@@ -16,6 +16,9 @@ import {
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { ResearchModeAcknowledgmentDialog } from "@/components/medications/ResearchModeAcknowledgmentDialog";
import { formatDateTime } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
/**
@@ -26,6 +29,17 @@ import { useTranslations } from "@/lib/i18n/context";
* What stays here is the irreversible danger-zone — the "wipe all my
* data" surface that should never live next to a single-click export
* button.
*
* v1.4.25 W19c-Frontend adds the Research Mode toggle on this page.
* Research Mode and the data-reset are both opt-in / version-gated
* controls; they share a semantic shelf even though they touch
* different parts of the user record. Toggle ON opens the
* acknowledgment dialog (the user reads + confirms before the server
* stamps the version). Toggle OFF fires DELETE directly. A separate
* amber banner above the toggle catches the version-mismatch case
* (server bumped the disclaimer while the user was still
* acknowledged) and surfaces a Re-acknowledge CTA that re-opens the
* dialog.
*/
export function AdvancedSection() {
const { t } = useTranslations();
@@ -47,11 +61,166 @@ export function AdvancedSection() {
</p>
</header>
<ResearchModeCard />
<DataResetCard />
</section>
);
}
interface ResearchModeStatus {
enabled: boolean;
acknowledgedAt: string | null;
acknowledgedVersion: string | null;
currentDisclaimerVersion: string;
}
function ResearchModeCard() {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [dialogOpen, setDialogOpen] = useState(false);
const [toggleBusy, setToggleBusy] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const { data: status, isLoading } = useQuery<ResearchModeStatus | null>({
queryKey: ["research-mode"],
queryFn: async () => {
const res = await fetch("/api/auth/me/research-mode");
if (!res.ok) return null;
const json = await res.json();
return json.data as ResearchModeStatus;
},
staleTime: 60 * 1000,
});
const versionsAligned =
!!status &&
status.acknowledgedVersion === status.currentDisclaimerVersion;
const showRePrompt = !!status?.enabled && !versionsAligned;
// The Switch's `checked` mirrors the server flag exactly — even when
// the version is stale we keep the toggle "on" so the user sees that
// their previous choice is preserved; the banner above the toggle
// explains why the chart isn't painting.
const switchChecked = !!status?.enabled;
async function handleToggle(next: boolean) {
if (toggleBusy) return;
setErrorMessage(null);
if (next) {
// Toggle ON → open the acknowledgment dialog. The dialog owns
// the POST; on success it invalidates the `research-mode` query
// and the toggle reflects the new state.
setDialogOpen(true);
return;
}
// Toggle OFF → fire DELETE directly.
setToggleBusy(true);
try {
const res = await fetch("/api/auth/me/research-mode", {
method: "DELETE",
});
if (!res.ok) {
setErrorMessage(t("settings.researchMode.disableError"));
return;
}
queryClient.invalidateQueries({ queryKey: ["research-mode"] });
} catch {
setErrorMessage(t("settings.researchMode.disableError"));
} finally {
setToggleBusy(false);
}
}
return (
<div
className="bg-card border-border rounded-xl border p-6"
data-slot="settings-research-mode-card"
>
<div className="flex items-center gap-2">
<BookOpenCheck className="text-dracula-purple h-5 w-5" />
<h2 className="text-lg font-semibold">
{t("settings.researchMode.sectionTitle")}
</h2>
</div>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.researchMode.subtitle")}
</p>
{showRePrompt && (
<div
role="alert"
data-slot="settings-research-mode-reprompt"
className="border-warning/40 bg-warning/10 mt-4 rounded-md border-l-4 px-3 py-2 text-sm"
>
<p className="text-foreground font-medium">
{t("settings.researchMode.rePromptTitle")}
</p>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.researchMode.rePromptBody")}
</p>
<Button
size="sm"
variant="outline"
className="mt-2"
onClick={() => setDialogOpen(true)}
data-slot="settings-research-mode-reprompt-cta"
>
{t("settings.researchMode.rePromptCta")}
</Button>
</div>
)}
<div className="mt-4 flex items-start justify-between gap-3">
<div className="space-y-0.5">
<p className="text-sm font-medium">
{t("settings.researchMode.toggleLabel")}
</p>
<p
className="text-muted-foreground text-xs"
data-slot="settings-research-mode-status"
>
{isLoading
? t("common.loading")
: status?.enabled
? versionsAligned
? t("settings.researchMode.acknowledgedOn", {
date: status.acknowledgedAt
? formatDateTime(status.acknowledgedAt)
: "—",
})
: t("settings.researchMode.enabledStaleStatus")
: t("settings.researchMode.disabledStatus")}
</p>
</div>
<Switch
checked={switchChecked}
disabled={isLoading || toggleBusy}
onCheckedChange={(next) => {
void handleToggle(next);
}}
aria-label={t("settings.researchMode.toggleLabel")}
data-slot="settings-research-mode-toggle"
/>
</div>
{errorMessage && (
<p
role="alert"
className="text-destructive mt-3 text-sm"
data-slot="settings-research-mode-error"
>
{errorMessage}
</p>
)}
<ResearchModeAcknowledgmentDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
currentDisclaimerVersion={status?.currentDisclaimerVersion ?? null}
/>
</div>
);
}
function DataResetCard() {
const { t } = useTranslations();
const queryClient = useQueryClient();