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( + /]*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(); + + // 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( + /]*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(); + + 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( + /]*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(); + + // 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(, "de"); + + expect(html).toContain("Forschungsmodus"); + expect(html).toContain("Geschätzte Wirkstoffkurve anzeigen"); + expect(html).toContain("Deaktiviert"); + }); +}); diff --git a/src/components/settings/advanced-section.tsx b/src/components/settings/advanced-section.tsx index 70d4c373e..4979507bd 100644 --- a/src/components/settings/advanced-section.tsx +++ b/src/components/settings/advanced-section.tsx @@ -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() {

+ ); } +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 ( +
+
+ +

+ {t("settings.researchMode.sectionTitle")} +

+
+

+ {t("settings.researchMode.subtitle")} +

+ + {showRePrompt && ( +
+

+ {t("settings.researchMode.rePromptTitle")} +

+

+ {t("settings.researchMode.rePromptBody")} +

+ +
+ )} + +
+
+

+ {t("settings.researchMode.toggleLabel")} +

+

+ {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")} +

+
+ { + void handleToggle(next); + }} + aria-label={t("settings.researchMode.toggleLabel")} + data-slot="settings-research-mode-toggle" + /> +
+ + {errorMessage && ( +

+ {errorMessage} +

+ )} + + +
+ ); +} + function DataResetCard() { const { t } = useTranslations(); const queryClient = useQueryClient();