From 20a72d2e32d38e321dc3f77d3fa6659da36e1c81 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 14 May 2026 20:30:51 +0200
Subject: [PATCH] feat(settings): research-mode toggle with version re-prompt
banner
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
messages/de.json | 14 +-
messages/en.json | 14 +-
messages/es.json | 16 +-
messages/fr.json | 16 +-
messages/it.json | 16 +-
messages/pl.json | 16 +-
.../__tests__/advanced-research-mode.test.tsx | 191 ++++++++++++++++++
src/components/settings/advanced-section.tsx | 173 +++++++++++++++-
8 files changed, 444 insertions(+), 12 deletions(-)
create mode 100644 src/components/settings/__tests__/advanced-research-mode.test.tsx
diff --git a/messages/de.json b/messages/de.json
index b8d45a3fd..02535493f 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -1582,6 +1582,18 @@
"settings": {
"title": "Einstellungen",
"subtitle": "Profil, Sicherheit & Verbindungen",
+ "researchMode": {
+ "sectionTitle": "Forschungsmodus",
+ "subtitle": "Optionale Anzeige einer geschätzten GLP-1-Wirkstoffkurve auf Basis der von der EMA veröffentlichten Populations-Pharmakokinetik. Nur zur Anschauung — niemals eine Messung oder ein medizinischer Rat.",
+ "toggleLabel": "Geschätzte Wirkstoffkurve anzeigen",
+ "disabledStatus": "Deaktiviert. Die Wirkstoffkurve ist ausgeblendet.",
+ "enabledStaleStatus": "Der Disclaimer wurde aktualisiert. Bestätige unten erneut, um die Kurve wieder anzuzeigen.",
+ "acknowledgedOn": "Bestätigt am {date}.",
+ "rePromptTitle": "Disclaimer aktualisiert",
+ "rePromptBody": "Der Disclaimer des Forschungsmodus wurde seit deiner letzten Bestätigung aktualisiert. Lies ihn erneut und bestätige, damit die Kurve sichtbar bleibt.",
+ "rePromptCta": "Disclaimer erneut bestätigen",
+ "disableError": "Forschungsmodus konnte nicht deaktiviert werden. Bitte erneut versuchen."
+ },
"shell": {
"sectionsNav": "Einstellungs-Bereiche"
},
@@ -1667,7 +1679,7 @@
},
"advanced": {
"title": "Erweitert",
- "description": "Account-weiter Notausgang — unwiderruflich alle Gesundheitsdaten löschen, Account bleibt erhalten."
+ "description": "Opt-in-Schalter für den Forschungsmodus sowie der Notausgang zum unwiderruflichen Löschen aller Gesundheitsdaten."
},
"export": {
"title": "Export",
diff --git a/messages/en.json b/messages/en.json
index 9710307fd..d8ce2dee6 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -1582,6 +1582,18 @@
"settings": {
"title": "Settings",
"subtitle": "Profile, Security & Connections",
+ "researchMode": {
+ "sectionTitle": "Research Mode",
+ "subtitle": "Opt-in display of an estimated GLP-1 drug-level curve, derived from EMA-published population pharmacokinetics. Reading-only — never a measurement or medical advice.",
+ "toggleLabel": "Show the estimated drug-level chart",
+ "disabledStatus": "Disabled. The drug-level chart is hidden.",
+ "enabledStaleStatus": "The disclaimer was updated. Re-acknowledge below to bring the chart back.",
+ "acknowledgedOn": "Acknowledged on {date}.",
+ "rePromptTitle": "Disclaimer updated",
+ "rePromptBody": "The Research Mode disclaimer was updated since you last acknowledged it. Re-read and re-confirm to keep the chart visible.",
+ "rePromptCta": "Re-acknowledge disclaimer",
+ "disableError": "Could not disable Research Mode. Please try again."
+ },
"shell": {
"sectionsNav": "Settings sections"
},
@@ -1667,7 +1679,7 @@
},
"advanced": {
"title": "Advanced",
- "description": "Account-wide danger zone — irreversibly wipe every health record while keeping your account."
+ "description": "Opt-in Research Mode toggle plus the account-wide danger zone for wiping every health record."
},
"export": {
"title": "Export",
diff --git a/messages/es.json b/messages/es.json
index 5c159d81e..527cda99e 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -1582,6 +1582,18 @@
"settings": {
"title": "Ajustes",
"subtitle": "Profile, Security & Connections",
+ "researchMode": {
+ "sectionTitle": "Modo Investigación",
+ "subtitle": "Visualización opcional de una curva estimada del nivel de fármaco GLP-1, derivada de la farmacocinética poblacional publicada por la EMA. Solo lectura: nunca una medición ni un consejo médico.",
+ "toggleLabel": "Mostrar la curva estimada del nivel de fármaco",
+ "disabledStatus": "Desactivado. La curva está oculta.",
+ "enabledStaleStatus": "El disclaimer se actualizó. Reconfirma debajo para volver a mostrar la curva.",
+ "acknowledgedOn": "Confirmado el {date}.",
+ "rePromptTitle": "Disclaimer actualizado",
+ "rePromptBody": "El disclaimer del modo Investigación se actualizó desde tu última confirmación. Relelo y reconfirma para mantener la curva visible.",
+ "rePromptCta": "Reconfirmar disclaimer",
+ "disableError": "No se pudo desactivar el modo Investigación. Inténtalo de nuevo."
+ },
"shell": {
"sectionsNav": "Settings sections"
},
@@ -1666,8 +1678,8 @@
"description": "Bearer tokens for your own scripts and apps — log measurements or medication intake from anywhere."
},
"advanced": {
- "title": "Advanced",
- "description": "Account-wide danger zone — irreversibly wipe every health record while keeping your account."
+ "title": "Avanzado",
+ "description": "Activador opt-in del modo Investigación y zona de peligro para borrar irrevocablemente todos los registros de salud."
},
"export": {
"title": "Exportar",
diff --git a/messages/fr.json b/messages/fr.json
index 5623c48d5..3b1bffc72 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -1582,6 +1582,18 @@
"settings": {
"title": "Paramètres",
"subtitle": "Profile, Security & Connections",
+ "researchMode": {
+ "sectionTitle": "Mode Recherche",
+ "subtitle": "Affichage optionnel d'une courbe de niveau estimé de médicament GLP-1, dérivée de la pharmacocinétique de population publiée par l'EMA. Lecture seule — jamais une mesure ni un conseil médical.",
+ "toggleLabel": "Afficher la courbe estimée du niveau de médicament",
+ "disabledStatus": "Désactivé. La courbe est masquée.",
+ "enabledStaleStatus": "Le disclaimer a été mis à jour. Reconfirmez ci-dessous pour réafficher la courbe.",
+ "acknowledgedOn": "Confirmé le {date}.",
+ "rePromptTitle": "Disclaimer mis à jour",
+ "rePromptBody": "Le disclaimer du mode Recherche a été mis à jour depuis votre dernière confirmation. Relisez-le et reconfirmez pour garder la courbe visible.",
+ "rePromptCta": "Reconfirmer le disclaimer",
+ "disableError": "Impossible de désactiver le mode Recherche. Veuillez réessayer."
+ },
"shell": {
"sectionsNav": "Settings sections"
},
@@ -1666,8 +1678,8 @@
"description": "Bearer tokens for your own scripts and apps — log measurements or medication intake from anywhere."
},
"advanced": {
- "title": "Advanced",
- "description": "Account-wide danger zone — irreversibly wipe every health record while keeping your account."
+ "title": "Avancé",
+ "description": "Bouton opt-in du mode Recherche et zone dangereuse pour effacer irrévocablement toutes les données de santé."
},
"export": {
"title": "Exporter",
diff --git a/messages/it.json b/messages/it.json
index 00641ab67..91b8750ce 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -1582,6 +1582,18 @@
"settings": {
"title": "Impostazioni",
"subtitle": "Profile, Security & Connections",
+ "researchMode": {
+ "sectionTitle": "Modalità Ricerca",
+ "subtitle": "Visualizzazione opzionale di una curva stimata del livello del farmaco GLP-1, derivata dalla farmacocinetica di popolazione pubblicata dall'EMA. Solo lettura: mai una misurazione né un consiglio medico.",
+ "toggleLabel": "Mostra la curva stimata del livello del farmaco",
+ "disabledStatus": "Disattivata. La curva è nascosta.",
+ "enabledStaleStatus": "Il disclaimer è stato aggiornato. Riconferma sotto per rivedere la curva.",
+ "acknowledgedOn": "Confermato il {date}.",
+ "rePromptTitle": "Disclaimer aggiornato",
+ "rePromptBody": "Il disclaimer della modalità Ricerca è stato aggiornato dall'ultima tua conferma. Rileggilo e riconferma per mantenere visibile la curva.",
+ "rePromptCta": "Riconferma disclaimer",
+ "disableError": "Impossibile disattivare la modalità Ricerca. Riprova."
+ },
"shell": {
"sectionsNav": "Settings sections"
},
@@ -1666,8 +1678,8 @@
"description": "Bearer tokens for your own scripts and apps — log measurements or medication intake from anywhere."
},
"advanced": {
- "title": "Advanced",
- "description": "Account-wide danger zone — irreversibly wipe every health record while keeping your account."
+ "title": "Avanzate",
+ "description": "Interruttore opt-in della modalità Ricerca e zona pericolosa per cancellare in modo irrevocabile tutti i dati di salute."
},
"export": {
"title": "Esporta",
diff --git a/messages/pl.json b/messages/pl.json
index 4dcdfdf87..de4295d3c 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -1582,6 +1582,18 @@
"settings": {
"title": "Ustawienia",
"subtitle": "Profile, Security & Connections",
+ "researchMode": {
+ "sectionTitle": "Tryb Badawczy",
+ "subtitle": "Opcjonalne wyświetlanie szacowanej krzywej poziomu leku GLP-1, wyprowadzonej z farmakokinetyki populacyjnej opublikowanej przez EMA. Tylko do odczytu — nigdy pomiar ani porada medyczna.",
+ "toggleLabel": "Pokaż szacowaną krzywą poziomu leku",
+ "disabledStatus": "Wyłączone. Krzywa jest ukryta.",
+ "enabledStaleStatus": "Disclaimer został zaktualizowany. Potwierdź ponownie poniżej, aby przywrócić krzywą.",
+ "acknowledgedOn": "Potwierdzono {date}.",
+ "rePromptTitle": "Zaktualizowano disclaimer",
+ "rePromptBody": "Disclaimer trybu Badawczego został zaktualizowany od czasu twojego ostatniego potwierdzenia. Przeczytaj go ponownie i potwierdź, aby krzywa pozostała widoczna.",
+ "rePromptCta": "Potwierdź ponownie disclaimer",
+ "disableError": "Nie udało się wyłączyć trybu Badawczego. Spróbuj ponownie."
+ },
"shell": {
"sectionsNav": "Settings sections"
},
@@ -1666,8 +1678,8 @@
"description": "Bearer tokens for your own scripts and apps — log measurements or medication intake from anywhere."
},
"advanced": {
- "title": "Advanced",
- "description": "Account-wide danger zone — irreversibly wipe every health record while keeping your account."
+ "title": "Zaawansowane",
+ "description": "Opt-in przełącznika trybu Badawczego oraz strefa zagrożenia umożliwiająca nieodwracalne wymazanie wszystkich danych zdrowotnych."
},
"export": {
"title": "Eksportuj",
diff --git a/src/components/settings/__tests__/advanced-research-mode.test.tsx b/src/components/settings/__tests__/advanced-research-mode.test.tsx
new file mode 100644
index 000000000..f0097fc60
--- /dev/null
+++ b/src/components/settings/__tests__/advanced-research-mode.test.tsx
@@ -0,0 +1,191 @@
+/**
+ * v1.4.25 W19c-Frontend — `` 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 = {};
+
+function setQueryResult(keyJoined: string, data: unknown) {
+ queryResults[keyJoined] = data;
+}
+
+vi.mock("@tanstack/react-query", () => ({
+ useQuery: ({ queryKey }: { queryKey: unknown[] }) => {
+ const key = (queryKey as Array).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;
+ }) => (
+
+ ),
+}));
+
+import { AdvancedSection } from "../advanced-section";
+
+function render(node: React.ReactNode, locale: "en" | "de" = "en") {
+ return renderToStaticMarkup(
+ {node},
+ );
+}
+
+beforeEach(() => {
+ for (const k of Object.keys(queryResults)) delete queryResults[k];
+});
+
+describe(" → 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();
+
+ 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(
+ /
+
);
}
+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(null);
+
+ const { data: status, isLoading } = useQuery({
+ 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 (
+