diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index cb1029861..201096698 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -7083,6 +7083,8 @@ components: type: boolean insights: type: boolean + medications: + type: boolean doctorReport: type: boolean additionalProperties: false @@ -12737,6 +12739,9 @@ components: insights: type: boolean description: Whether the "insights" module is enabled for this account. + medications: + type: boolean + description: Whether the "medications" module is enabled for this account. doctorReport: type: boolean description: Whether the "doctorReport" module is enabled for this account. @@ -12752,6 +12757,7 @@ components: - achievements - coach - insights + - medications - doctorReport additionalProperties: false description: Fully-resolved per-user module enable/disable map. Every toggleable module key is present. `false` means diff --git a/messages/de.json b/messages/de.json index cdbaa9522..a0665ff79 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3902,7 +3902,7 @@ "inboxLink": "Posteingang öffnen" }, "layout": { - "title": "Layout & Personalisierung", + "title": "Layout", "description": "Lege fest, wie Dashboard und Insights aufgebaut und angeordnet sind.", "backToHub": "Zurück zu Layout & Personalisierung", "dashboard": { @@ -4156,6 +4156,10 @@ "coach": { "title": "Coach", "description": "Coach aktivieren, Ton und Umfang, gespeicherte Erinnerungen." + }, + "channels": { + "title": "Kanäle", + "description": "Zustellung über Telegram, ntfy, Web-Push, Webhook und E-Mail." } }, "profile": "Profil", @@ -4867,7 +4871,17 @@ "rate_limited": "Zu viele Verbindungsversuche. Warte einen Moment und versuche es erneut.", "generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut." }, - "integrationSetupGuide": "Einrichtungsanleitung" + "integrationSetupGuide": "Einrichtungsanleitung", + "coach": { + "activate": { + "title": "Coach aktivieren", + "description": "Zeigt den Coach-Button und das Coach-Panel. Standardmäßig an — ausschalten, um den Coach überall auszublenden.", + "toggleAria": "Coach-Button und Coach-Panel aktivieren", + "savedOn": "Coach ist aktiviert.", + "savedOff": "Coach ist ausgeblendet.", + "saveError": "Speichern fehlgeschlagen. Bitte erneut versuchen." + } + } }, "admin": { "title": "Administration", @@ -6734,7 +6748,7 @@ "description": "KI-Chat mit Bezug zu deinen Daten." }, "insights": { - "label": "Auswertungen", + "label": "Insights", "description": "KI-Analysen, Briefing und Korrelationen." }, "doctorReport": { diff --git a/messages/en.json b/messages/en.json index 7ca9be83e..6f6bb9e82 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3902,7 +3902,7 @@ "inboxLink": "Open the inbox" }, "layout": { - "title": "Layout & Personalization", + "title": "Layout", "description": "Arrange how the dashboard and insights are laid out.", "backToHub": "Back to Layout & Personalization", "dashboard": { @@ -4156,6 +4156,10 @@ "coach": { "title": "Coach", "description": "Enable the Coach, tone and scope, stored memories." + }, + "channels": { + "title": "Channels", + "description": "Telegram, ntfy, Web Push, Webhook, and Email delivery." } }, "profile": "Profile", @@ -4867,7 +4871,17 @@ "rate_limited": "Too many connection attempts. Wait a moment and try again.", "generic": "Unknown error while connecting. Please start the connection again." }, - "integrationSetupGuide": "Setup guide" + "integrationSetupGuide": "Setup guide", + "coach": { + "activate": { + "title": "Activate Coach", + "description": "Show the Coach button and drawer. On by default — turn it off to hide the Coach everywhere.", + "toggleAria": "Activate the Coach button and drawer", + "savedOn": "Coach activated.", + "savedOff": "Coach hidden.", + "saveError": "Could not save the change. Please try again." + } + } }, "admin": { "title": "Administration", diff --git a/messages/es.json b/messages/es.json index 4edefab7d..7fb8cfb38 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3902,7 +3902,7 @@ "inboxLink": "Abrir la bandeja de entrada" }, "layout": { - "title": "Diseño y personalización", + "title": "Diseño", "description": "Organiza cómo están dispuestos el panel y los análisis.", "backToHub": "Volver a Diseño y personalización", "dashboard": { @@ -4156,6 +4156,10 @@ "coach": { "title": "Coach", "description": "Activa el Coach, tono y alcance, recuerdos guardados." + }, + "channels": { + "title": "Canales", + "description": "Envío por Telegram, ntfy, Web Push, Webhook y correo electrónico." } }, "profile": "Perfil", @@ -4867,7 +4871,17 @@ "rate_limited": "Demasiados intentos de conexión. Espera un momento e inténtalo de nuevo.", "generic": "Error desconocido al conectar. Inicia la conexión de nuevo." }, - "integrationSetupGuide": "Guía de configuración" + "integrationSetupGuide": "Guía de configuración", + "coach": { + "activate": { + "title": "Activar el Coach", + "description": "Muestra el botón y el panel del Coach. Activado por defecto: desactívalo para ocultar el Coach en todas partes.", + "toggleAria": "Activar el botón y el panel del Coach", + "savedOn": "Coach activado.", + "savedOff": "Coach oculto.", + "saveError": "No se pudo guardar el cambio. Inténtalo de nuevo." + } + } }, "admin": { "title": "Administration", @@ -6734,7 +6748,7 @@ "description": "Chat de IA basado en tus propios datos." }, "insights": { - "label": "Análisis", + "label": "Insights", "description": "Análisis de IA, resumen y correlaciones." }, "doctorReport": { diff --git a/messages/fr.json b/messages/fr.json index e2644cd04..f49bfae54 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3902,7 +3902,7 @@ "inboxLink": "Ouvrir la boîte de réception" }, "layout": { - "title": "Disposition et personnalisation", + "title": "Disposition", "description": "Organisez la disposition du tableau de bord et des analyses.", "backToHub": "Retour à Disposition et personnalisation", "dashboard": { @@ -4156,6 +4156,10 @@ "coach": { "title": "Coach", "description": "Activez le Coach, le ton et la portée, les souvenirs enregistrés." + }, + "channels": { + "title": "Canaux", + "description": "Distribution via Telegram, ntfy, Web Push, Webhook et e-mail." } }, "profile": "Profil", @@ -4867,7 +4871,17 @@ "rate_limited": "Trop de tentatives de connexion. Patientez un instant et réessayez.", "generic": "Erreur inconnue lors de la connexion. Veuillez relancer la connexion." }, - "integrationSetupGuide": "Guide de configuration" + "integrationSetupGuide": "Guide de configuration", + "coach": { + "activate": { + "title": "Activer le Coach", + "description": "Affiche le bouton et le panneau du Coach. Activé par défaut — désactivez-le pour masquer le Coach partout.", + "toggleAria": "Activer le bouton et le panneau du Coach", + "savedOn": "Coach activé.", + "savedOff": "Coach masqué.", + "saveError": "Impossible d enregistrer la modification. Veuillez réessayer." + } + } }, "admin": { "title": "Administration", @@ -6734,7 +6748,7 @@ "description": "Chat IA fondé sur vos propres données." }, "insights": { - "label": "Analyses", + "label": "Insights", "description": "Analyses IA, briefing et corrélations." }, "doctorReport": { diff --git a/messages/it.json b/messages/it.json index e6c673f46..71172582a 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3902,7 +3902,7 @@ "inboxLink": "Apri la posta in arrivo" }, "layout": { - "title": "Layout e personalizzazione", + "title": "Layout", "description": "Organizza la disposizione del cruscotto e degli approfondimenti.", "backToHub": "Torna a Layout e personalizzazione", "dashboard": { @@ -4156,6 +4156,10 @@ "coach": { "title": "Coach", "description": "Attiva il Coach, tono e ambito, ricordi salvati." + }, + "channels": { + "title": "Canali", + "description": "Recapito tramite Telegram, ntfy, Web Push, Webhook ed e-mail." } }, "profile": "Profilo", @@ -4867,7 +4871,17 @@ "rate_limited": "Troppi tentativi di connessione. Attendi un momento e riprova.", "generic": "Errore sconosciuto durante la connessione. Riavvia la connessione." }, - "integrationSetupGuide": "Guida alla configurazione" + "integrationSetupGuide": "Guida alla configurazione", + "coach": { + "activate": { + "title": "Attiva il Coach", + "description": "Mostra il pulsante e il pannello del Coach. Attivo per impostazione predefinita: disattivalo per nascondere il Coach ovunque.", + "toggleAria": "Attiva il pulsante e il pannello del Coach", + "savedOn": "Coach attivato.", + "savedOff": "Coach nascosto.", + "saveError": "Impossibile salvare la modifica. Riprova." + } + } }, "admin": { "title": "Administration", @@ -6734,7 +6748,7 @@ "description": "Chat IA basata sui tuoi dati." }, "insights": { - "label": "Analisi", + "label": "Insights", "description": "Analisi IA, briefing e correlazioni." }, "doctorReport": { diff --git a/messages/pl.json b/messages/pl.json index 34d735c60..66756a704 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3902,7 +3902,7 @@ "inboxLink": "Otwórz skrzynkę odbiorczą" }, "layout": { - "title": "Układ i personalizacja", + "title": "Układ", "description": "Ustal, jak rozłożone są pulpit i analizy.", "backToHub": "Powrót do Układ i personalizacja", "dashboard": { @@ -4156,6 +4156,10 @@ "coach": { "title": "Coach", "description": "Włącz Coacha, ton i zakres, zapisane wspomnienia." + }, + "channels": { + "title": "Kanały", + "description": "Dostarczanie przez Telegram, ntfy, Web Push, Webhook i e-mail." } }, "profile": "Profil", @@ -4867,7 +4871,17 @@ "rate_limited": "Zbyt wiele prób połączenia. Poczekaj chwilę i spróbuj ponownie.", "generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie." }, - "integrationSetupGuide": "Przewodnik konfiguracji" + "integrationSetupGuide": "Przewodnik konfiguracji", + "coach": { + "activate": { + "title": "Włącz Coacha", + "description": "Pokazuje przycisk i panel Coacha. Domyślnie włączony — wyłącz, aby ukryć Coacha wszędzie.", + "toggleAria": "Włącz przycisk i panel Coacha", + "savedOn": "Coach włączony.", + "savedOff": "Coach ukryty.", + "saveError": "Nie udało się zapisać zmiany. Spróbuj ponownie." + } + } }, "admin": { "title": "Administracja", @@ -6734,7 +6748,7 @@ "description": "Czat AI oparty na Twoich danych." }, "insights": { - "label": "Analizy", + "label": "Insights", "description": "Analizy AI, briefing i korelacje." }, "doctorReport": { diff --git a/next.config.ts b/next.config.ts index b4f889925..7413f86b2 100644 --- a/next.config.ts +++ b/next.config.ts @@ -121,15 +121,9 @@ const nextConfig: NextConfig = { destination: "/coach", permanent: true, }, - // v1.18.0 (S3) — Sources folded into Settings → Integrations as the - // "Sources" sub-tab; the standalone `/settings/sources` page is gone. - // 301-redirect so bookmarks, the PWA's cached navigation, and the old - // cross-links keep resolving. - { - source: "/settings/sources", - destination: "/settings/integrations", - permanent: true, - }, + // v1.18.1 (D4) — Sources is a standalone `/settings/sources` route again + // (split back out of the Integrations sub-tabs), so the v1.18.0 redirect + // to `/settings/integrations` was dropped. // v1.18.0 (S4) — the standalone "Erinnerungen" hub at // `/settings/reminders` was a link-only page that duplicated the // canonical editors. Reminder TYPES now live in Notifications, each diff --git a/src/app/api/__tests__/module-route-gate-inventory.test.ts b/src/app/api/__tests__/module-route-gate-inventory.test.ts index 7d1629fa6..57a5daca1 100644 --- a/src/app/api/__tests__/module-route-gate-inventory.test.ts +++ b/src/app/api/__tests__/module-route-gate-inventory.test.ts @@ -86,6 +86,17 @@ const MODULE_ROUTE_TREES: ReadonlyArray = [ // surface over a Bearer token when the account never opted in. "src/app/api/illness", "src/app/api/gamification", + // v1.18.1 (D3) — medications graduated from CORE to a toggleable module. + // It is SURFACE-gated (nav entry, dashboard widget, the dedicated + // Medikamente settings entry), not data-layer-gated: every `/api/medications/*` + // route is raw medication CRUD / intake / inventory / compliance over the + // user's own rows, so they are EXEMPT below under the same data-layer + // reasoning as mood/labs — disabling the module hides the surface, it does + // not wedge an importer / sync / the user's ability to clean up, and + // re-enabling finds the schedule + intake history intact. Walking the tree + // means a NEW medication route must justify itself (gate or document-exempt) + // rather than silently appearing. + "src/app/api/medications", // v1.18.0 B3 — the legacy `/api/doctor-report` tree (JSON + server-PDF + // availability probe) was orphaned dead code (no production caller) and // removed. The live doctor-report / FHIR surface is `/api/export/health-record`, @@ -147,6 +158,42 @@ const EXEMPT_ROUTES: ReadonlyArray = [ "src/app/api/labs/restore/route.ts", "src/app/api/biomarkers/route.ts", "src/app/api/biomarkers/[id]/route.ts", + // ── DATA LAYER (medications) ────────────────────────────────────── + // v1.18.1 (D3) — medications graduated from CORE to a toggleable module, + // but it is SURFACE-gated (nav / dashboard widget / settings entry), not + // data-layer-gated. Every `/api/medications/*` route is raw CRUD over the + // user's own medication / intake / inventory / compliance / side-effect + // rows — the same data-layer reasoning as mood/labs: the module gate + // governs whether medications SURFACES, not whether the row store accepts + // writes. An importer / sync / the user's ability to clean up entries must + // keep working while the module is off, and re-enabling must find the + // schedule + intake history intact. + "src/app/api/medications/route.ts", + "src/app/api/medications/layout/route.ts", + "src/app/api/medications/compliance/route.ts", + "src/app/api/medications/intake/route.ts", + "src/app/api/medications/intake/bulk/route.ts", + // NB: `medications/extract` is NOT exempt — it gates on + // `requireAssistantSurface("coach")` (the NL-extraction is an assistant + // surface), so the inventory already counts it as a delegated gate. + "src/app/api/medications/[id]/route.ts", + "src/app/api/medications/[id]/api-endpoint/route.ts", + "src/app/api/medications/[id]/cadence/route.ts", + "src/app/api/medications/[id]/compliance/route.ts", + "src/app/api/medications/[id]/dose-history/route.ts", + "src/app/api/medications/[id]/glp1/route.ts", + "src/app/api/medications/[id]/phase-config/route.ts", + "src/app/api/medications/[id]/intake/route.ts", + "src/app/api/medications/[id]/intake/[eventId]/route.ts", + "src/app/api/medications/[id]/intake/bulk-delete/route.ts", + "src/app/api/medications/[id]/intake/import/route.ts", + "src/app/api/medications/[id]/intake/purge/route.ts", + "src/app/api/medications/[id]/inventory/route.ts", + "src/app/api/medications/[id]/inventory/[itemId]/route.ts", + "src/app/api/medications/[id]/schedule-revisions/route.ts", + "src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts", + "src/app/api/medications/[id]/side-effects/route.ts", + "src/app/api/medications/[id]/side-effects/[logId]/route.ts", // ── INFRA / UI-ONLY ─────────────────────────────────────────────── // Static FHIR CapabilityStatement — server metadata, no user data. "src/app/api/fhir/metadata/route.ts", @@ -281,6 +328,7 @@ describe("module API route gate inventory", () => { "coach", "doctorReport", "insights", + "medications", ] as const) { expect(known.has(key), `unknown module key in inventory: ${key}`).toBe( true, diff --git a/src/app/api/auth/me/modules/__tests__/route.test.ts b/src/app/api/auth/me/modules/__tests__/route.test.ts index d7d80ce70..4f5322d44 100644 --- a/src/app/api/auth/me/modules/__tests__/route.test.ts +++ b/src/app/api/auth/me/modules/__tests__/route.test.ts @@ -237,7 +237,9 @@ describe("PATCH /api/auth/me/modules", () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); primeUser(); - for (const core of ["weight", "bloodPressure", "pulse", "medications"]) { + // v1.18.1 (D3) — medications graduated to a toggleable module, so only + // weight / blood pressure / pulse stay refused as core domains. + for (const core of ["weight", "bloodPressure", "pulse"]) { const res = await (PATCH as (r: Request) => Promise)( mkPatch({ [core]: false }), ); diff --git a/src/app/api/auth/me/modules/route.ts b/src/app/api/auth/me/modules/route.ts index c846f4368..b5f9080ec 100644 --- a/src/app/api/auth/me/modules/route.ts +++ b/src/app/api/auth/me/modules/route.ts @@ -12,10 +12,11 @@ * * The body schema (`modulePrefsPatchSchema`) is `strict()` over the * directly-owned toggleable key set, so a core-domain key (`weight`, - * `bloodPressure`, `pulse`, `medications`), a delegated key (`cycle`, - * `coach`), or any unknown key is a 422 — the core measurement engine + - * meds can never be disabled here, and a value for a delegated module can - * never land inert in `modulePreferencesJson`. The delegated modules are + * `bloodPressure`, `pulse`), a delegated key (`cycle`, `coach`), or any + * unknown key is a 422 — the core measurement engine can never be disabled + * here, and a value for a delegated module can never land inert in + * `modulePreferencesJson`. v1.18.1 (D3) — `medications` graduated to a + * toggleable module, so it IS accepted here now. The delegated modules are * managed at their real control (cycle in Account, coach in Settings → * Coach); the Modules hub deep-links there rather than offering a dead * toggle. diff --git a/src/app/settings/[section]/page.tsx b/src/app/settings/[section]/page.tsx index 151e90266..78115b334 100644 --- a/src/app/settings/[section]/page.tsx +++ b/src/app/settings/[section]/page.tsx @@ -18,9 +18,10 @@ import { ModulesSection } from "@/components/settings/modules-section"; import { MoodSection } from "@/components/settings/mood-section"; import { NotificationsSection } from "@/components/settings/notifications-section"; import { SectionPlaceholder } from "@/components/settings/section-placeholder"; -// v1.18.0 (S3) — `sources` no longer has a standalone route; source priority -// renders inside Settings → Integrations as the "Sources" sub-tab. -// `/settings/sources` 301-redirects to `/settings/integrations` (next.config.ts). +// v1.18.1 (D4) — `channels` and `sources` are standalone left-side entries +// again (split out of the Integrations sub-tabs). +import { ChannelsSection } from "@/components/settings/channels-section"; +import { SourcesSection } from "@/components/settings/sources-section"; import { ThresholdsSection } from "@/components/settings/thresholds-section"; import { SETTINGS_SECTION_SLUGS, @@ -55,6 +56,8 @@ const SECTION_COMPONENTS: Record< ai: AiSection, coach: CoachSection, integrations: IntegrationsSection, + channels: ChannelsSection, + sources: SourcesSection, notifications: NotificationsSection, layout: LayoutSection, dashboard: DashboardSection, diff --git a/src/components/layout/__tests__/nav-model.test.ts b/src/components/layout/__tests__/nav-model.test.ts index 7b43cc980..a694b42b5 100644 --- a/src/components/layout/__tests__/nav-model.test.ts +++ b/src/components/layout/__tests__/nav-model.test.ts @@ -115,7 +115,7 @@ describe("visibleNavDestinations module gate", () => { expect(on.indexOf("/cycle")).toBeLessThan(on.indexOf("/insights")); }); - it("drops a module-gated entry (mood / labs / coach / achievements / insights) when its module is disabled", () => { + it("drops a module-gated entry (mood / labs / coach / achievements / insights / medications) when its module is disabled", () => { const disabled = visibleNavDestinations({ mood: false, labs: false, @@ -124,12 +124,15 @@ describe("visibleNavDestinations module gate", () => { // v1.18.0 — Insights is now `requiresModule: "insights"`, so the // top-level /insights entry drops when the module is off. insights: false, + // v1.18.1 (D3) — medications graduated to a toggleable module. + medications: false, }).map((d) => d.href); expect(disabled).not.toContain("/mood"); expect(disabled).not.toContain("/labs"); expect(disabled).not.toContain("/coach"); expect(disabled).not.toContain("/achievements"); expect(disabled).not.toContain("/insights"); + expect(disabled).not.toContain("/medications"); }); it("keeps every module-gated entry when its module is enabled", () => { @@ -139,12 +142,14 @@ describe("visibleNavDestinations module gate", () => { coach: true, achievements: true, insights: true, + medications: true, }).map((d) => d.href); expect(enabled).toContain("/mood"); expect(enabled).toContain("/labs"); expect(enabled).toContain("/coach"); expect(enabled).toContain("/achievements"); expect(enabled).toContain("/insights"); + expect(enabled).toContain("/medications"); }); it("fails open: a missing key or an undefined map keeps the gated entry", () => { @@ -159,7 +164,8 @@ describe("visibleNavDestinations module gate", () => { expect(hrefs).toContain("/coach"); expect(hrefs).toContain("/achievements"); } - // Core destinations always render regardless of the map. + // Core / fail-open destinations render regardless of the map. Medications + // is module-gated (v1.18.1 D3) but fails open on a missing key. expect(emptyMap).toContain("/measurements"); expect(emptyMap).toContain("/medications"); expect(emptyMap).toContain("/insights"); diff --git a/src/components/layout/nav-model.ts b/src/components/layout/nav-model.ts index 822711344..89eb41a8b 100644 --- a/src/components/layout/nav-model.ts +++ b/src/components/layout/nav-model.ts @@ -49,8 +49,8 @@ export interface NavDestination { * v1.18.0 — gate the entry on a per-user module toggle. When set, the * entry is dropped unless the account's resolved module map (from * `GET /api/auth/me`'s `modules`) has the key enabled. Core destinations - * (weight / BP / pulse / medications + always-on pages) carry no key and - * always render. `cycle` and `coach` are delegated keys (cycle → gender + + * (weight / BP / pulse + always-on pages) carry no key and always render. + * `cycle` and `coach` are delegated keys (cycle → gender + * opt-in, coach → operator flag + per-user opt-out); the auth/me map * already reflects that delegation, so reading them here is correct and * not a re-derivation. @@ -83,6 +83,9 @@ export const NAV_DESTINATIONS: ReadonlyArray = [ tKey: "nav.medications", icon: Pill, tourId: "nav-medications", + // v1.18.1 (D3) — medications graduated from a CORE domain to a toggleable + // module; the nav entry now drops when the account turns the module off. + requiresModule: "medications", }, { href: "/cycle", diff --git a/src/components/settings/__tests__/coach-section.test.tsx b/src/components/settings/__tests__/coach-section.test.tsx index 09c8be1a4..16f2f34d9 100644 --- a/src/components/settings/__tests__/coach-section.test.tsx +++ b/src/components/settings/__tests__/coach-section.test.tsx @@ -42,25 +42,26 @@ function render(locale: "en" | "de" = "en") { } describe(" — SSR smoke", () => { - it("renders the section scaffold + the Hide-Coach toggle card", () => { + it("renders the section scaffold + the Activate-Coach toggle card", () => { authState.disableCoach = false; const html = render(); expect(html).toContain('id="settings-section-coach-title"'); expect(html).toContain('data-testid="settings-disable-coach-card"'); expect(html).toContain('data-testid="settings-disable-coach-switch"'); - expect(html).toContain("Hide Coach"); - expect(html).toContain("Hides the Coach button and drawer everywhere."); + // v1.18.1 (D7) — polarity flipped to activate/default-on. + expect(html).toContain("Activate Coach"); + expect(html).toContain("Show the Coach button and drawer. On by default"); // Raw key never leaks past i18n. expect(html).not.toContain("settings.sections.coach."); + expect(html).not.toContain("settings.coach.activate."); }); - it("renders the German Hide-Coach copy", () => { + it("renders the German Activate-Coach copy", () => { authState.disableCoach = false; const html = render("de"); - expect(html).toContain("Coach ausblenden"); - expect(html).toContain( - "Versteckt den Coach-Button und das Coach-Panel auf allen Seiten.", - ); + expect(html).toContain("Coach aktivieren"); + expect(html).toContain("Standardmäßig an"); expect(html).not.toContain("settings.sections.coach."); + expect(html).not.toContain("settings.coach.activate."); }); }); diff --git a/src/components/settings/__tests__/disable-coach-card.test.tsx b/src/components/settings/__tests__/disable-coach-card.test.tsx index b93cbea27..495fa9a59 100644 --- a/src/components/settings/__tests__/disable-coach-card.test.tsx +++ b/src/components/settings/__tests__/disable-coach-card.test.tsx @@ -1,5 +1,12 @@ /** - * v1.4.47 W3 — Settings → AI "Hide Coach" toggle persistence contract. + * v1.4.47 W3 — Settings → Coach activation toggle persistence contract. + * + * v1.18.1 (D7) — the card reads "Activate Coach" (default ON). The persisted + * column is still `User.disableCoach`; the Switch shows the activated view + * (`checked = !disableCoach`). The SSR render is `mounted=false`, where the + * card paints the default-ON (checked) state so the default-on contract holds + * before the wire value resolves — both default and opted-out users render + * checked under `renderToStaticMarkup`. * * The card is a small client-only `` that PATCHes * `/api/auth/me/disable-coach` and invalidates `queryKeys.authMe()` so @@ -114,33 +121,33 @@ function render(): string { } describe("Settings — DisableCoachCard", () => { - it("seeds the Switch in the off state for a default user", () => { + it("renders the activation Switch checked at SSR (default-on contract)", () => { const html = render(); - // The Switch primitive surfaces `data-state="unchecked"` when - // `checked={false}` is passed by the parent — pin the contract so - // the SSR shape matches the user's `disableCoach: false` field. + // v1.18.1 (D7) — the SSR render is `mounted=false`, where the card paints + // the default-ON (activated → checked) state regardless of the wire value, + // so the Switch never flashes off before /me resolves. expect(html).toContain('data-testid="settings-disable-coach-switch"'); - expect(html).toContain('data-state="unchecked"'); + expect(html).toContain('data-state="checked"'); }); - it("seeds the Switch in the on state for an opted-out user", () => { + it("paints the activated (checked) SSR state even for an opted-out user", () => { authSpy.mockImplementation(() => ({ user: buildUser(true), isAuthenticated: true, })); const html = render(); expect(html).toContain('data-testid="settings-disable-coach-switch"'); - // The shadcn Switch surfaces `data-state="checked"` on the root - // when `checked={true}`. Pre-fix the card painted `unchecked` for - // an opted-out user; this assertion guards that regression. + // The activated state resolves on the first client re-render once + // `mounted` flips; the SSR pass stays on the default-ON shape. expect(html).toContain('data-state="checked"'); }); - it("Switch description string matches the QoL audit (M2) copy", () => { + it("renders the Coach-activation title + description", () => { const html = render(); - // The project-voice contract: "Hides the Coach button and drawer - // everywhere." (English) — must surface verbatim because the QoL - // findings doc cites this exact phrasing. - expect(html).toContain("Hides the Coach button and drawer everywhere."); + // v1.18.1 (D7) — polarity flipped to activate/default-on. + expect(html).toContain("Activate Coach"); + expect(html).toContain( + "Show the Coach button and drawer. On by default", + ); }); }); diff --git a/src/components/settings/__tests__/medications-section.test.tsx b/src/components/settings/__tests__/medications-section.test.tsx index 3ff5eb734..7fc9b3d70 100644 --- a/src/components/settings/__tests__/medications-section.test.tsx +++ b/src/components/settings/__tests__/medications-section.test.tsx @@ -80,12 +80,11 @@ beforeEach(() => { }); describe(" — SSR smoke", () => { - it("renders the section heading + description via i18n (no raw keys)", () => { + it("renders the section heading via i18n (no raw keys)", () => { + // v1.18.1 (D0) — the section blurb was dropped; the sr-only heading + the + // card content carry the meaning. const html = render(); expect(html).toContain("settings-section-medications-title"); - expect(html).toContain( - "List view, order, and injection sites for your medications.", - ); expect(html).not.toContain("settings.sections."); expect(html).not.toContain("medications.view"); expect(html).not.toContain("medications.reorder"); @@ -93,9 +92,6 @@ describe(" — SSR smoke", () => { it("renders the German copy end-to-end", () => { const html = render("de"); - expect(html).toContain( - "Listenansicht, Reihenfolge und Injektionsstellen deiner Medikamente.", - ); expect(html).toContain("Reihenfolge anpassen"); expect(html).toContain( "Wähle, ob deine Medikamente als Karten oder als Tabelle angezeigt werden.", diff --git a/src/components/settings/__tests__/mood-section.test.tsx b/src/components/settings/__tests__/mood-section.test.tsx index f933bc633..8f3975704 100644 --- a/src/components/settings/__tests__/mood-section.test.tsx +++ b/src/components/settings/__tests__/mood-section.test.tsx @@ -147,17 +147,17 @@ beforeEach(() => { }); describe(" — SSR smoke", () => { - it("renders the section heading + description via i18n (no raw keys)", () => { + it("renders the section heading via i18n (no raw keys)", () => { + // v1.18.1 (D0) — the section blurb was dropped; the sr-only heading + the + // card content carry the meaning. const html = render(); expect(html).toContain("settings-section-mood-title"); - expect(html).toContain("Groups, tags, and archived tags for the mood picker."); expect(html).not.toContain("settings.sections."); expect(html).not.toContain("mood.manage."); }); it("renders the German copy end-to-end", () => { const html = render("de"); - expect(html).toContain("Gruppen, Tags und archivierte Tags der Stimmungs-Auswahl."); expect(html).toContain("Neue Gruppe"); expect(html).toContain("Neuer Tag"); expect(html).toContain("Archivierte Tags"); diff --git a/src/components/settings/__tests__/notifications-section-module-gating.test.tsx b/src/components/settings/__tests__/notifications-section-module-gating.test.tsx index 185d6b65e..4cc5cd46b 100644 --- a/src/components/settings/__tests__/notifications-section-module-gating.test.tsx +++ b/src/components/settings/__tests__/notifications-section-module-gating.test.tsx @@ -76,9 +76,15 @@ describe(" module-gated reminder types", () => { expect(html).not.toContain('id="coach-nudge"'); }); - it("shows the channels pointer (delivery channels live under Integrations)", () => { + it("drops the channels / inbox cross-links and the Vorsorge block (D5)", () => { + // v1.18.1 (D5) — the page is lean: no section blurb, no channels/inbox + // cross-links, and no embedded Vorsorge editor (it has its own page). const html = render({}); - expect(html).toContain('data-slot="notifications-channels-cross-link"'); - expect(html).toContain('href="/settings/integrations"'); + expect(html).not.toContain('data-slot="notifications-channels-cross-link"'); + expect(html).not.toContain('data-slot="notifications-inbox-cross-link"'); + // The three reminder-type cards remain. + expect(html).toContain('id="mood-reminder"'); + expect(html).toContain('id="low-stock"'); + expect(html).toContain('id="coach-nudge"'); }); }); diff --git a/src/components/settings/__tests__/sections.test.tsx b/src/components/settings/__tests__/sections.test.tsx index fab34abc4..1eddc6cca 100644 --- a/src/components/settings/__tests__/sections.test.tsx +++ b/src/components/settings/__tests__/sections.test.tsx @@ -119,17 +119,21 @@ describe("settings sections — SSR smoke", () => { expect(html).not.toContain("settings.sections."); }); - it(" exposes a Restart-tour button for B5 replay", () => { - const html = render(); - // v1.4.15 phase B5 acceptance criterion 4: Settings → Account - // exposes a "Restart onboarding tour" button that resets the - // server-side flag. Rendered as a card next to Password reset. + it(" exposes the Restart-tour button (moved from Account)", () => { + // v1.18.1 (D1) — the "Restart onboarding tour" card moved from Settings → + // Account into Settings → Advanced (a maintenance/reset action). + const html = render(); expect(html).toContain('data-testid="settings-restart-tour"'); expect(html).toContain("Restart onboarding tour"); // No raw i18n key leaks. expect(html).not.toContain("onboarding.tour."); }); + it(" no longer carries the Restart-tour button", () => { + const html = render(); + expect(html).not.toContain('data-testid="settings-restart-tour"'); + }); + it(" renders version + license + sources/docs cards (no Updates panel)", () => { const html = render(); expect(html).toContain("About"); @@ -184,17 +188,14 @@ describe("settings sections — SSR smoke", () => { it(" renders the single reminder-types home", () => { const html = render(); - // v1.18.0 (S4) — one clean heading (sr-only h1 + visible description), - // no stacked breadcrumb. The screen is the single module-gated home for - // reminder TYPES. + // v1.18.1 (D0/D5) — the screen is lean: sr-only h1 only, no section blurb, + // no channels/inbox cross-links, and no embedded Vorsorge editor. expect(html).toContain("settings-section-notifications-title"); - // The old stacked breadcrumb middle crumb is gone. expect(html).not.toContain("Notification channels"); - // A single concise pointer to the channels (now under Integrations). - expect(html).toContain('href="/settings/integrations"'); - expect(html).toContain('data-slot="notifications-channels-cross-link"'); + expect(html).not.toContain('data-slot="notifications-channels-cross-link"'); + expect(html).not.toContain('data-slot="notifications-inbox-cross-link"'); // The mood + coach cards fail OPEN (mock user carries no module map), - // so both reminder-type cards render. + // so all three reminder-type cards render. expect(html).toContain('id="mood-reminder"'); expect(html).toContain('id="coach-nudge"'); expect(html).toContain('id="low-stock"'); diff --git a/src/components/settings/__tests__/settings-shell.test.tsx b/src/components/settings/__tests__/settings-shell.test.tsx index a7fbe3a8f..ce27d54d2 100644 --- a/src/components/settings/__tests__/settings-shell.test.tsx +++ b/src/components/settings/__tests__/settings-shell.test.tsx @@ -89,10 +89,14 @@ describe("SETTINGS_SECTION_SLUGS", () => { // out of Export & Import into its own top-level entry, before `export`; // `coach` gathers the Coach preference cards out of the AI section and // sits right after `ai`. + // v1.18.1 (D4) — `channels` + `sources` split out of the Integrations + // sub-tabs into their own left-side entries, right after `integrations`. expect([...SETTINGS_SECTION_SLUGS]).toEqual([ "account", "modules", "integrations", + "channels", + "sources", "notifications", "layout", "dashboard", @@ -264,12 +268,14 @@ describe("", () => { // `/notifications` inbox now shares the "Notifications" label. expect(html).toContain("Notifications"); // v1.17.1 (F-2) — the dashboard + insights arrangement editors are - // reached through one Layout hub entry. + // reached through one Layout hub entry. v1.18.1 (D6) — renamed to "Layout". expect(html).toContain('href="/settings/layout"'); - expect(html).toContain("Layout & Personalization"); - // v1.18.0 (S5) — Medications (Medikamente, always shown — a CORE - // domain) and Mood (Stimmung, gated; fail-open mock shows it) are their - // own nav entries now. + expect(html).toContain(">Layout"); + // v1.18.1 (D4) — Channels + Sources are their own left-side entries. + expect(html).toContain('href="/settings/channels"'); + expect(html).toContain('href="/settings/sources"'); + // v1.18.0 (S5) — Medications (Medikamente) and Mood (Stimmung, gated; + // fail-open mock shows both) are their own nav entries now. expect(html).toContain('href="/settings/medications"'); expect(html).toContain('href="/settings/mood"'); // The ampersand is HTML-escaped by React SSR — assert on the encoded @@ -285,10 +291,10 @@ describe("", () => { // About is back in the settings nav as the last entry (it also // stays linked from the sidebar user-card dropdown). expect(html).toContain('href="/settings/about"'); - // v1.18.0 (S3) — Targets keeps its own nav entry; Sources folded into - // Integrations as a sub-tab, so it is no longer a standalone nav entry. + // v1.18.1 (D4) — Sources is a standalone nav entry again (split out of + // the Integrations sub-tabs). Targets keeps its own entry. expect(html).toContain('href="/settings/thresholds"'); - expect(html).not.toContain('href="/settings/sources"'); + expect(html).toContain('href="/settings/sources"'); expect(html).toContain("Targets"); }); @@ -301,14 +307,17 @@ describe("", () => { // inbox now shares the "Benachrichtigungen" label. expect(html).toContain("Benachrichtigungen"); // v1.17.1 (F-2) — the arrangement editors are reached through one - // Layout hub entry ("Layout & Personalisierung"). + // Layout hub entry. v1.18.1 (D6) — renamed to "Layout" in every locale. expect(html).toContain('href="/settings/layout"'); - expect(html).toContain("Layout & Personalisierung"); + expect(html).toContain(">Layout"); + // v1.18.1 (D4) — Channels ("Kanäle") + Sources ("Quellen-Priorität") + // are their own left-side entries. + expect(html).toContain('href="/settings/channels"'); + expect(html).toContain("Kanäle"); // v1.18.0 (S5) — Medications + Mood are their own nav entries now. expect(html).toContain('href="/settings/medications"'); expect(html).toContain('href="/settings/mood"'); - // v1.18.0 (S3) — Targets keeps its German nav entry ("Zielwerte"); - // Sources folded into Integrations as a sub-tab. + // Targets keeps its German nav entry ("Zielwerte"). expect(html).toContain("Zielwerte"); // v1.8.7.1 — the AI Insights section is named "KI-Auswertungen" in // German (the "KI" prefix makes the AI nature explicit). @@ -319,8 +328,8 @@ describe("", () => { // "Über diese App" (About) is back as the last in-shell nav entry // (the sidebar user-card dropdown keeps its own link too). expect(html).toContain('href="/settings/about"'); - // v1.18.0 (S3) — Sources is no longer a standalone nav entry. - expect(html).not.toContain('href="/settings/sources"'); + // v1.18.1 (D4) — Sources is a standalone nav entry again. + expect(html).toContain('href="/settings/sources"'); }); it("does NOT surface the raw key when a translation resolves — guards against missing JSON entries", () => { diff --git a/src/components/settings/about-section.tsx b/src/components/settings/about-section.tsx index 95f921318..e79e452f8 100644 --- a/src/components/settings/about-section.tsx +++ b/src/components/settings/about-section.tsx @@ -244,13 +244,10 @@ export function AboutSection({ hideTourReplay = false }: AboutSectionProps = {}) aria-labelledby="settings-section-about-title" className="space-y-6" > -
+

{t("settings.sections.about.title")}

-

- {t("settings.sections.about.description")} -

{/* Identity card — version + license inline. The v1.4.2 layout diff --git a/src/components/settings/account-section.tsx b/src/components/settings/account-section.tsx index 72ce6cdc3..bcd976592 100644 --- a/src/components/settings/account-section.tsx +++ b/src/components/settings/account-section.tsx @@ -5,7 +5,6 @@ import { useRouter } from "next/navigation"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertTriangle, - Compass, ImageUp, KeyRound, Loader2, @@ -46,9 +45,9 @@ import { useMounted } from "@/hooks/use-mounted"; import { formatDate } from "@/lib/format"; import { locales, localeLabels, type Locale } from "@/lib/i18n/config"; import { useTranslations } from "@/lib/i18n/context"; -import { restartOnboardingTour } from "@/lib/onboarding/tour-restart"; import { describePasskeyError } from "@/lib/passkey-errors"; import { queryKeys } from "@/lib/query-keys"; +import { AboutMeSection } from "@/components/settings/about-me-section"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { TimezonePicker } from "@/components/settings/timezone-picker"; import { TimeFormatSelect } from "@/components/settings/time-format-select"; @@ -162,22 +161,6 @@ export function AccountSection() { >(null); const [passwordDialogOpen, setPasswordDialogOpen] = useState(false); - // v1.4.15 Phase B5 — onboarding-tour replay state. Settings → Account - // exposes a "Restart onboarding tour" button that resets the - // server-side flag (`users.onboarding_tour_completed = false`) and - // dispatches a `healthlog:tour-restart` window event so the dashboard's - // picks it up immediately on the user's next nav. The - // confirmation message goes through the same announce channel as - // every other settings save. - // v1.4.48 M6c — text + type collapsed into one discriminated state so - // the two values can never drift (same shape as ``'s - // replay button so the surface stays mirrored). - const [tourRestarting, setTourRestarting] = useState(false); - const [tourFeedback, setTourFeedback] = useState<{ - key: string; - type: "success" | "error"; - } | null>(null); - // Passkey registration state. const [passkeyLoading, setPasskeyLoading] = useState(false); const [passkeyMsg, setPasskeyMsg] = useState(null); @@ -328,29 +311,6 @@ export function AccountSection() { } } - async function handleRestartTour() { - setTourRestarting(true); - setTourFeedback(null); - // v1.4.48 M6b — both Settings → Account and Settings → About now - // delegate to the shared `restartOnboardingTour()` worker so the - // server flip + force-launch marker + window event live in one - // place. Account additionally refetches the auth payload so the - // `onboardingTourCompleted` flag the launcher reads matches the - // server flip immediately — About has no auth handle to refetch - // and relies on the next navigation re-running `/api/auth/me`. - const result = await restartOnboardingTour(user?.id); - if (result.ok) { - await refetch(); - setTourFeedback({ - key: "onboarding.tour.restartConfirmation", - type: "success", - }); - } else { - setTourFeedback({ key: result.messageKey, type: "error" }); - } - setTourRestarting(false); - } - async function handleChangePassword(e: React.FormEvent) { e.preventDefault(); setPasswordSaving(true); @@ -412,13 +372,13 @@ export function AccountSection() { aria-labelledby="settings-section-account-title" className="space-y-6" > -
+ {/* v1.18.1 (D0) — the explanatory section blurb was dropped so every + settings section starts at the same vertical height; the sr-only + heading stays for the accessibility tree. */} +

{t("settings.sections.account.title")}

-

- {t("settings.sections.account.description")} -

{/* Profile card */} @@ -636,6 +596,13 @@ export function AccountSection() { Medikamente settings section, where every medication-specific preference now lives. */} + {/* v1.18.1 (D8) — the AI "About me" context lives under Profil, placed + before Zyklus-Tracking. It is personal medical context (conditions, + allergies, what the Coach should watch) the daily briefing + Coach + read, so it belongs with the account profile rather than the AI + provider-configuration screen. */} + + {/* Cycle-tracking enable on-ramp — auto-on for female accounts, but this lets any account opt in (or opt out) before the gated /cycle page is reachable. */} @@ -710,54 +677,9 @@ export function AccountSection() { - {/* Tour replay card. v1.4.15 Phase B5: a one-shot button that - resets `users.onboarding_tour_completed` on the server AND - dispatches a window event so a dashboard already in the - background reopens the spotlight tour immediately. v1.4.19 - A6 — same stack-on-mobile / right-align-on-desktop contract - as the password card so both action surfaces look identical. */} -
-
-
- -
-

- {t("onboarding.tour.restart")} -

-

- {t("onboarding.tour.restartHint")} -

-
-
- -
- {tourFeedback && ( -

- {t(tourFeedback.key)} -

- )} -
+ {/* v1.18.1 (D1) — the "Tour neu starten" card moved to Settings → + Erweitert. It is a maintenance / reset action, not a profile or + security control, so it sits beside Research Mode + the danger zone. */} -
+ {/* v1.18.1 (D0) — section blurb dropped for consistent top alignment. */} +

{t("settings.sections.advanced.title")}

-

- {t("settings.sections.advanced.description")} -

+ {/* v1.18.1 (D1) — "Tour neu starten" relocated here from Account. */} + @@ -72,6 +74,85 @@ export function AdvancedSection() { ); } +/** + * v1.18.1 (D1) — onboarding-tour replay, relocated from Settings → Account. + * + * Resets `users.onboarding_tour_completed` on the server and dispatches a + * `healthlog:tour-restart` window event so a dashboard already in the + * background reopens the spotlight tour immediately. Delegates to the shared + * `restartOnboardingTour()` worker (the same one Settings → About uses) and + * refetches the auth payload so the launcher's `onboardingTourCompleted` flag + * matches the server flip without a navigation. + */ +function TourReplayCard() { + const { t } = useTranslations(); + const { user, refetch } = useAuth(); + const [restarting, setRestarting] = useState(false); + const [feedback, setFeedback] = useState<{ + key: string; + type: "success" | "error"; + } | null>(null); + + async function handleRestartTour() { + setRestarting(true); + setFeedback(null); + const result = await restartOnboardingTour(user?.id); + if (result.ok) { + await refetch(); + setFeedback({ + key: "onboarding.tour.restartConfirmation", + type: "success", + }); + } else { + setFeedback({ key: result.messageKey, type: "error" }); + } + setRestarting(false); + } + + return ( +
+
+
+ +
+

+ {t("onboarding.tour.restart")} +

+

+ {t("onboarding.tour.restartHint")} +

+
+
+ +
+ {feedback && ( +

+ {t(feedback.key)} +

+ )} +
+ ); +} + /** * v1.4.25 W21 Fix-N (simp-H3) — pull the nested ternary that decided * the toggle's caption out of the JSX. The four-way ladder (loading / diff --git a/src/components/settings/ai-section.tsx b/src/components/settings/ai-section.tsx index d30e8c7e7..2ee988267 100644 --- a/src/components/settings/ai-section.tsx +++ b/src/components/settings/ai-section.tsx @@ -10,11 +10,14 @@ * * v1.18.0 (S5) — the Coach preference cards (disable toggle, preferences, * memory) moved out to the dedicated Coach section. The AI section keeps - * only provider / model / BYOK configuration plus the "about me" context - * (which the daily briefing reads too, so it is not Coach-only). + * only provider / model / BYOK configuration. + * + * v1.18.1 (D8) — the "About me" context moved to Settings → Account (under + * Profil, before Zyklus-Tracking). It is personal medical context the daily + * briefing reads too, so it belongs with the account profile rather than the + * provider-configuration screen. */ -import { AboutMeSection } from "@/components/settings/about-me-section"; import { AiInsightsCard } from "@/components/settings/ai/ai-insights-card"; import { useAuth } from "@/hooks/use-auth"; import { useMounted } from "@/hooks/use-mounted"; @@ -34,22 +37,17 @@ export function AiSection() { return (
-
+ {/* v1.18.1 (D0) — section blurb dropped for consistent top alignment. */} +

{t("settings.sections.ai.title")}

-

- {t("settings.sections.ai.description")} -

- {/* v1.15.20 — user-authored "about me" context for the Coach + - daily briefing. Not gated on the Coach toggle: the briefing - reads it too, so it stays in the AI section rather than the - Coach section. */} - + {/* v1.18.1 (D8) — the "About me" context moved to Settings → Account + (under Profil, before Zyklus-Tracking). */}
); } diff --git a/src/components/settings/ai/disable-coach-card.tsx b/src/components/settings/ai/disable-coach-card.tsx index 89e067fc7..4a1f8687a 100644 --- a/src/components/settings/ai/disable-coach-card.tsx +++ b/src/components/settings/ai/disable-coach-card.tsx @@ -1,26 +1,32 @@ "use client"; /** - * v1.4.47 W3 — "Hide Coach" toggle card. + * v1.4.47 W3 — Coach activation toggle card. * - * Persists via `PATCH /api/auth/me/disable-coach`; the response - * invalidates `queryKeys.authMe()` so every Coach mount point on the - * client (``, ``, the inline - * `` pill, the `/targets` page CTAs) re-renders - * with the new `user.disableCoach` value on the next React Query - * refetch tick — no full reload required. + * v1.18.1 (D7) — polarity flip. The card now reads "Activate Coach" with the + * Switch ON by default (the Coach is on for everyone unless the user opts + * out). The persisted state is still the single `User.disableCoach` field — + * one source of truth — so the Switch's `checked` is `!disableCoach` and a + * flip writes `disableCoach: !next`. Presenting it as activate/default-on is + * a pure UI inversion over the same column. * - * The optimistic-update pattern mirrors ``: the - * Switch flips immediately, the mutation rolls back on error. + * Persists via `PATCH /api/auth/me/disable-coach`; the response invalidates + * `queryKeys.authMe()` so every Coach mount point on the client + * (``, ``, the inline `` + * pill, the `/targets` page CTAs) re-renders with the new `user.disableCoach` + * value on the next React Query refetch tick — no full reload required. * - * v1.16.4 — header follows the shared `SettingsCardHeader` contract - * (icon column + title + switch in the status slot) so the card reads - * like its siblings instead of carrying its own bespoke header row. + * The optimistic-update pattern mirrors ``: the Switch + * flips immediately, the mutation rolls back on error. + * + * v1.16.4 — header follows the shared `SettingsCardHeader` contract (icon + * column + title + switch in the status slot) so the card reads like its + * siblings instead of carrying its own bespoke header row. */ import { useEffect, useRef, useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { MessageCircleOff } from "lucide-react"; +import { MessageCircleHeart } from "lucide-react"; import { Switch } from "@/components/ui/switch"; import { SettingsCardHeader } from "@/components/settings/_card-header"; @@ -75,23 +81,28 @@ export function DisableCoachCard({ }, 3000); } - // `mounted`-gated: `user` comes from the auth query, which can - // resolve before this boundary hydrates — the Switch state must - // match the unchecked SSR HTML during hydration (React #418) and - // pick up the wire value on the first client re-render. + // `mounted`-gated: `user` comes from the auth query, which can resolve + // before this boundary hydrates — the Switch state must match the SSR HTML + // during hydration (React #418) and pick up the wire value on the first + // client re-render. v1.18.1 (D7) — the toggle now reads "Coach aktivieren" + // (default ON), so the Switch is checked when the Coach is ACTIVE. The + // persisted column is still `disableCoach`; `optimistic` and `checked` are + // the activated-state view of it. The SSR default is ON (checked) so the + // default-on contract holds before the wire value resolves. const mounted = useMounted(); - const checked = mounted ? (optimistic ?? user?.disableCoach ?? false) : false; + const activated = mounted ? (optimistic ?? !(user?.disableCoach ?? false)) : true; const mutation = useMutation({ + // `next` is the desired ACTIVATED state; the column stores the inverse. mutationFn: async (next: boolean) => { - await apiPatch("/api/auth/me/disable-coach", { disableCoach: next }); + await apiPatch("/api/auth/me/disable-coach", { disableCoach: !next }); return next; }, onSuccess: (next) => { setMsg( next - ? t("settings.ai.disableCoach.savedHidden") - : t("settings.ai.disableCoach.savedShown"), + ? t("settings.coach.activate.savedOn") + : t("settings.coach.activate.savedOff"), ); setMsgType("success"); // Surface the new value to every Coach gate via the shared @@ -106,7 +117,7 @@ export function DisableCoachCard({ setMsg( err instanceof Error ? err.message - : t("settings.ai.disableCoach.saveError"), + : t("settings.coach.activate.saveError"), ); setMsgType("error"); scheduleClear(); @@ -131,17 +142,17 @@ export function DisableCoachCard({ className="bg-card border-border rounded-xl border p-4 sm:p-6" > } /> diff --git a/src/components/settings/api-section.tsx b/src/components/settings/api-section.tsx index 105a39e45..8580b14cc 100644 --- a/src/components/settings/api-section.tsx +++ b/src/components/settings/api-section.tsx @@ -41,13 +41,10 @@ export function ApiSection() { return (
-
+

{t("settings.sections.api.title")}

-

- {t("settings.sections.api.description")} -

diff --git a/src/components/settings/channels-section.tsx b/src/components/settings/channels-section.tsx new file mode 100644 index 000000000..65da12c95 --- /dev/null +++ b/src/components/settings/channels-section.tsx @@ -0,0 +1,37 @@ +"use client"; + +/** + * `` — Settings → Kanäle (notification delivery channels). + * + * v1.18.1 (D4) — split out of the Integrations sub-tabs into its own left-side + * entry. A channel (Telegram / ntfy / Web Push / Webhook / Email) is a + * delivery provider — the same family as a connected device — and now reads as + * a first-class settings home rather than a tab hidden behind Integrationen. + * + * The content is the existing ``; this wrapper only + * owns the section frame (sr-only heading scaffold matching every other + * settings section). + */ + +import { NotificationChannelsPanel } from "@/components/settings/integrations/notification-channels-panel"; +import { useTranslations } from "@/lib/i18n/context"; + +export function ChannelsSection() { + const { t } = useTranslations(); + + return ( +
+ {/* v1.18.1 (D0) — section blurb dropped for consistent top alignment. */} +
+

+ {t("settings.sections.channels.title")} +

+
+ + +
+ ); +} diff --git a/src/components/settings/coach-section.tsx b/src/components/settings/coach-section.tsx index fc9563edf..2d732bad2 100644 --- a/src/components/settings/coach-section.tsx +++ b/src/components/settings/coach-section.tsx @@ -46,13 +46,11 @@ export function CoachSection() { aria-labelledby="settings-section-coach-title" className="space-y-6" > -
+ {/* v1.18.1 (D0) — section blurb dropped for consistent top alignment. */} +

{t("settings.sections.coach.title")}

-

- {t("settings.sections.coach.description")} -

diff --git a/src/components/settings/dashboard-section.tsx b/src/components/settings/dashboard-section.tsx index 346493ba2..70fa97477 100644 --- a/src/components/settings/dashboard-section.tsx +++ b/src/components/settings/dashboard-section.tsx @@ -20,9 +20,7 @@ export function DashboardSection() {

{t("settings.sections.dashboard.title")}

-

- {t("settings.sections.dashboard.description")} -

+ {/* v1.18.1 (D0) — section blurb dropped for consistent top alignment. */}
diff --git a/src/components/settings/export-section.tsx b/src/components/settings/export-section.tsx index 07b0463b0..f6cf3d354 100644 --- a/src/components/settings/export-section.tsx +++ b/src/components/settings/export-section.tsx @@ -75,13 +75,10 @@ export function ExportSection() { aria-labelledby="settings-section-export-title" className="space-y-6" > -
+

{t("settings.sections.export.title")}

-

- {t("settings.sections.export.description")} -

{/* v1.18.0 (S5) — the full health-record export moved to its own diff --git a/src/components/settings/gesundheitsakte-section.tsx b/src/components/settings/gesundheitsakte-section.tsx index 4a3a9bb3f..dfa9d047d 100644 --- a/src/components/settings/gesundheitsakte-section.tsx +++ b/src/components/settings/gesundheitsakte-section.tsx @@ -27,16 +27,14 @@ export function GesundheitsakteSection() { aria-labelledby="settings-section-gesundheitsakte-title" className="space-y-6" > -
+ {/* v1.18.1 (D0) — section blurb dropped for consistent top alignment. */} +

{t("settings.sections.gesundheitsakte.title")}

-

- {t("settings.sections.gesundheitsakte.description")} -

diff --git a/src/components/settings/health-record-export-panel.tsx b/src/components/settings/health-record-export-panel.tsx index ec21299e3..213b4e1e5 100644 --- a/src/components/settings/health-record-export-panel.tsx +++ b/src/components/settings/health-record-export-panel.tsx @@ -13,12 +13,11 @@ * name, charts) hide when the format is FHIR. The ePA-compat note shows * for the FHIR + package formats. * - * v1.12 — this is the export page's hero. It carries the same - * `hero-gradient + glow-purple` treatment as the Insights hero strip so - * the headline "data out" path owns the primary visual weight. The - * grouped data-section toggles live behind a collapsible disclosure, - * collapsed by default, so the panel opens compact instead of as a long - * always-expanded checklist. + * v1.18.1 (D9) — the panel renders as a normal settings card (the v1.12 + * gradient/glow hero treatment was dropped: the gradient read too crass next + * to the rest of the settings surface). The grouped data-section toggles live + * behind a collapsible disclosure, collapsed by default, so the panel opens + * compact instead of as a long always-expanded checklist. */ import { useId, useState } from "react"; @@ -196,11 +195,10 @@ export function HealthRecordExportPanel() { aria-labelledby="health-record-export-title" data-testid="health-record-export-panel" className={cn( - "hero-gradient glow-purple animate-insight-in", - // `isolate` traps the purple glow inside the hero so the shadow - // doesn't leak through the cards below — same trick the Insights - // `` uses. - "relative isolate overflow-hidden rounded-xl p-4 sm:p-5", + // v1.18.1 (D9) — the gradient/glow hero treatment dropped in favour + // of a normal card so the Gesundheitsakte reads like every other + // settings surface rather than shouting. + "bg-card border-border rounded-xl border p-4 sm:p-6", )} > {t("settings.sections.insights.title")} -

- {t("settings.sections.insights.description")} -

+ {/* v1.18.1 (D0) — section blurb dropped for consistent top alignment. */}
diff --git a/src/components/settings/integrations-section.tsx b/src/components/settings/integrations-section.tsx index 86e6d5ad0..6ed4792c8 100644 --- a/src/components/settings/integrations-section.tsx +++ b/src/components/settings/integrations-section.tsx @@ -1,31 +1,21 @@ "use client"; /** - * `` — Settings → Integrations. + * `` — Settings → Integrationen. * - * v1.18.0 (S3) — Integrations is now the one home for every delivery / ingest - * connection. Three sub-tabs under one nav entry: - * 1. Connections — Withings / WHOOP / Fitbit / Polar / Oura / Nightscout - * (the OAuth + device data sources). See `connections-panel.tsx`. - * 2. Channels — Telegram / ntfy / Web Push / Webhook / Email plus the live - * per-channel status surface (moved here from Settings → Notifications, - * where it never belonged: channels are delivery providers, not - * reminder preferences). See `notification-channels-panel.tsx`. - * 3. Sources — the per-metric source-priority + device-type ladders that - * decide which connection wins when two report the same metric. Folded in - * from the former standalone `/settings/sources` page. See - * `sources-section.tsx` (sub-tab mode). + * v1.18.1 (D4) — the three sub-tabs (Connections / Channels / Sources) were + * split into three separate left-side settings entries. The Connections + * content IS the Integrationen page now: clicking Integrationen shows the + * OAuth + device data sources (Withings / WHOOP / Fitbit / Polar / Oura / + * Nightscout) directly, with no tab strip. Channels (delivery providers) and + * Sources (source weighting) each got their own entry — see + * `channels-section.tsx` and the standalone `sources` route. * * `parseOAuthOutcome` / `oauthReasonKey` are re-exported from * `connections-panel.tsx` so existing unit-test imports keep resolving. */ -import { useState } from "react"; - -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { ConnectionsPanel } from "@/components/settings/integrations/connections-panel"; -import { NotificationChannelsPanel } from "@/components/settings/integrations/notification-channels-panel"; -import { SourcesSection } from "@/components/settings/sources-section"; import { useTranslations } from "@/lib/i18n/context"; export { @@ -33,52 +23,22 @@ export { oauthReasonKey, } from "@/components/settings/integrations/connections-panel"; -type IntegrationsTab = "connections" | "channels" | "sources"; - export function IntegrationsSection() { const { t } = useTranslations(); - const [tab, setTab] = useState("connections"); return (
-
+ {/* v1.18.1 (D0) — section blurb dropped for consistent top alignment. */} +

{t("settings.sections.integrations.title")}

-

- {t("settings.sections.integrations.description")} -

- setTab(value as IntegrationsTab)} - > - - - {t("settings.sections.integrations.tabs.connections")} - - - {t("settings.sections.integrations.tabs.channels")} - - - {t("settings.sections.integrations.tabs.sources")} - - - - - - - - - - - - - +
); } diff --git a/src/components/settings/layout-section.tsx b/src/components/settings/layout-section.tsx index cf4474ec4..a636c39af 100644 --- a/src/components/settings/layout-section.tsx +++ b/src/components/settings/layout-section.tsx @@ -58,13 +58,10 @@ export function LayoutSection() { aria-labelledby="settings-section-layout-title" className="space-y-6" > -
+

{t("settings.sections.layout.title")}

-

- {t("settings.sections.layout.description")} -

    diff --git a/src/components/settings/medications-section.tsx b/src/components/settings/medications-section.tsx index 61dce559d..54abf90c7 100644 --- a/src/components/settings/medications-section.tsx +++ b/src/components/settings/medications-section.tsx @@ -76,13 +76,10 @@ export function MedicationsSection() { aria-labelledby="settings-section-medications-title" className="space-y-6" > -
    +

    {t("settings.sections.medications.title")}

    -

    - {t("settings.sections.medications.description")} -

    {/* View preference — cards vs table. The shared header toggle diff --git a/src/components/settings/modules-section.tsx b/src/components/settings/modules-section.tsx index 67ff12410..95f1ae25b 100644 --- a/src/components/settings/modules-section.tsx +++ b/src/components/settings/modules-section.tsx @@ -11,11 +11,12 @@ * core keys) and then invalidates the `authMe()` query so the nav, Insights * pills, and dashboard tiles re-gate live off `useAuth().user.modules`. * - * The four CORE domains (weight, blood pressure, pulse, medications) render - * as a separate read-only "always on" group — locked switches with a short - * note — so the always-on measurement engine + meds read as deliberately - * fixed, never disableable. Palette stays neutral throughout; this is a - * calm configuration surface, not an alarm panel. + * The three CORE domains (weight, blood pressure, pulse) render as a + * separate read-only "always on" group — locked switches with a short note — + * so the always-on measurement engine reads as deliberately fixed, never + * disableable. Palette stays neutral throughout; this is a calm + * configuration surface, not an alarm panel. (v1.18.1 D3 — medications + * graduated to the toggleable group above.) * * State source is `useAuth().user.modules` (the resolved `/auth/me` map). * A module is enabled unless its key is explicitly `false` — default-on. @@ -71,6 +72,8 @@ const MODULE_ICONS: Record = { achievements: Award, coach: MessageCircleHeart, insights: Sparkles, + // v1.18.1 (D3) — medications graduated from CORE to a toggleable module. + medications: Pill, doctorReport: FileText, }; @@ -79,7 +82,6 @@ const CORE_ICONS: Record = { weight: Scale, bloodPressure: HeartPulse, pulse: Activity, - medications: Pill, }; export function ModulesSection() { @@ -116,21 +118,19 @@ export function ModulesSection() { aria-labelledby="settings-section-modules-title" className="space-y-6" > -
    + {/* v1.18.1 (D0) — section blurb dropped for consistent top alignment. */} +

    {t("settings.sections.modules.title")}

    -

    - {t("settings.sections.modules.description")} -

    - {/* Toggleable modules */} + {/* Toggleable modules. v1.18.1 (D2) — the explanatory line above the + first card dropped; the per-row labels carry the meaning. */}
    diff --git a/src/components/settings/mood-section.tsx b/src/components/settings/mood-section.tsx index f4a251e96..5f31a7b58 100644 --- a/src/components/settings/mood-section.tsx +++ b/src/components/settings/mood-section.tsx @@ -45,13 +45,10 @@ export function MoodSection() { aria-labelledby="settings-section-mood-title" className="space-y-6" > -
    +

    {t("settings.sections.mood.title")}

    -

    - {t("settings.sections.mood.description")} -

    {/* Groups — picker group order + own groups. */} diff --git a/src/components/settings/notifications-section.tsx b/src/components/settings/notifications-section.tsx index d57d23b4b..82a06fbb4 100644 --- a/src/components/settings/notifications-section.tsx +++ b/src/components/settings/notifications-section.tsx @@ -1,13 +1,10 @@ "use client"; -import Link from "next/link"; - import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import { CoachNudgeCard } from "@/components/settings/coach-nudge-card"; import { LowStockCard } from "@/components/settings/low-stock-card"; import { MoodReminderCard } from "@/components/settings/mood-reminder-card"; -import { VorsorgeSection } from "@/components/measurement-reminders/vorsorge-section"; /** * `` — Settings → Notifications ("Benachrichtigungen"). @@ -21,15 +18,14 @@ import { VorsorgeSection } from "@/components/measurement-reminders/vorsorge-sec * Each reminder-type card is shown ONLY when its module is enabled, read from * the resolved `useAuth().user.modules` map (the same `/auth/me` map nav and * Insights pills gate off). Mood → `mood`, Coach nudge → `coach`. Low-stock - * runway maps to the medications CORE domain, so it is always shown. Vorsorge - * (preventive-care) reminders are not a toggleable module, so they are always - * shown. The gate fails OPEN: a missing key reads as enabled, so a stale - * `/me` payload never blanks a card. + * runway maps to medications, which fails open (a missing key reads as + * enabled) so a stale `/me` payload never blanks a card. * - * Delivery CHANNELS (Telegram / ntfy / Web Push / Webhook / Email) are a - * different concept — a channel is a delivery provider, the same family as a - * connected device — and live under Settings → Integrations → Channels (moved - * there in S3). A single concise pointer below the heading links to them. + * v1.18.1 (D5) — the page is intentionally lean: the section blurb, the + * channels / inbox cross-links, and the embedded Vorsorge (preventive-care) + * editor were all removed. Vorsorge has its own `/vorsorge` page, so embedding + * it here was a duplicate. Delivery CHANNELS (Telegram / ntfy / Web Push / + * Webhook / Email) live under Settings → Integrations → Channels. */ export function NotificationsSection() { const { t } = useTranslations(); @@ -45,43 +41,18 @@ export function NotificationsSection() { aria-labelledby="settings-section-notifications-title" className="space-y-6" > -
    - {/* sr-only h1 — the visible section title is painted by the settings - shell nav; the in-page heading mirrors every other settings - section's scaffold so the screen reads as one of the family. */} + {/* v1.18.1 (D0/D5) — the section blurb and the channels / inbox cross- + links were dropped: the header now starts at the same height as every + other section and the page carries only the reminder-type cards. */} +

    {t("settings.sections.notifications.title")}

    -

    - {t("settings.sections.notifications.description")} -

    - {/* One concise pointer to the delivery channels (now under - Integrations) and one to the notification inbox — not a duplicated - section, just where-to-go links. */} -

    - {t("settings.sections.notifications.channelsHint")}{" "} - - {t("settings.sections.notifications.channelsHintLink")} - - {" · "} - - {t("settings.sections.notifications.inboxLink")} - -

    - {/* Vorsorge (preventive-care) reminders — always shown; not a - toggleable module. The canonical in-place editor, embedded here so - "what gets reminded and when" reads as one screen. */} - + {/* v1.18.1 (D5) — the Vorsorge (preventive-care) block was removed; it + has its own /vorsorge page, so embedding the editor here was a + duplicate. This screen keeps the three reminder-type cards below. */} {/* Mood check-in reminder — only when the mood module is enabled. */} {moodEnabled ? ( diff --git a/src/components/settings/section-placeholder.tsx b/src/components/settings/section-placeholder.tsx index 5dec2f479..4877945bf 100644 --- a/src/components/settings/section-placeholder.tsx +++ b/src/components/settings/section-placeholder.tsx @@ -19,9 +19,11 @@ import { FileHeart, Info, KeyRound, + Layers, LayoutDashboard, Link2, Pill, + Radio, Settings2, SlidersHorizontal, Smile, @@ -39,6 +41,8 @@ const SLUG_ICON: Record = { account: User, modules: Blocks, integrations: Link2, + channels: Radio, + sources: Layers, notifications: Bell, layout: LayoutDashboard, dashboard: LayoutDashboard, diff --git a/src/components/settings/section-slugs.ts b/src/components/settings/section-slugs.ts index 33ee0ff6c..284b05acf 100644 --- a/src/components/settings/section-slugs.ts +++ b/src/components/settings/section-slugs.ts @@ -6,10 +6,13 @@ * graph. */ -// v1.18.0 (S3) — `sources` is no longer a standalone slug. Source priority -// folded into Settings → Integrations as the "Sources" sub-tab (deciding which -// connection wins when two report the same metric is an Integrations concern). -// `/settings/sources` 301-redirects to `/settings/integrations` (next.config.ts). +// v1.18.0 (S3) — `sources` was folded into Settings → Integrations as the +// "Sources" sub-tab. +// v1.18.1 (D4) — the Integrations sub-tabs (Connections / Channels / Sources) +// were split back into three separate left-side entries: `integrations` (the +// Connections content, shown directly), `channels` (delivery channels), and +// `sources` (source weighting). The `/settings/sources` redirect is gone +// (it is a real route again); `channels` is a brand-new entry. // `thresholds` (Targets) keeps its own left-nav entry + route, served by the // dynamic `[section]` route below. // v1.16.10 — `medications` sits after `insights`: the customise surface @@ -40,6 +43,10 @@ export const SETTINGS_SECTION_SLUGS = [ // single front door for enabling/disabling secondary tracking domains. "modules", "integrations", + // v1.18.1 (D4) — Kanäle (delivery channels) + Quellen (source weighting) + // split out of the Integrations tabs into their own left-side entries. + "channels", + "sources", "notifications", "layout", "dashboard", diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx index 9e773d6eb..e47b640a4 100644 --- a/src/components/settings/settings-shell.tsx +++ b/src/components/settings/settings-shell.tsx @@ -26,8 +26,10 @@ import { Info, KeyRound, LayoutDashboard, + Layers, Link2, Pill, + Radio, Settings2, SlidersHorizontal, Smile, @@ -105,6 +107,20 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [ titleKey: "settings.sections.integrations.title", icon: Link2, }, + // v1.18.1 (D4) — Kanäle (delivery channels) split out of the Integrations + // sub-tabs into its own entry. A channel is a delivery provider. + { + slug: "channels", + titleKey: "settings.sections.channels.title", + icon: Radio, + }, + // v1.18.1 (D4) — Quellen (source weighting) split out into its own entry: + // which connection wins when two report the same metric. + { + slug: "sources", + titleKey: "settings.sections.sources.title", + icon: Layers, + }, // v1.18.0 (S4) — "Benachrichtigungen" is now the single module-gated // reminder-types home. The separate "Erinnerungen" hub (a link-only page) // is gone; reminder TYPES live here, each row gated on its module. @@ -124,12 +140,15 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [ icon: LayoutDashboard, }, // v1.18.0 (S5) — Medikamente: medication-specific settings (list view + - // order + injection sites). Medications is a CORE domain, so the entry is - // always shown. Was a hidden child of the Layout hub. + // order + injection sites). Was a hidden child of the Layout hub. + // v1.18.1 (D3) — medications graduated from a CORE domain to a toggleable + // module, so this entry now hides when the account turns the module off + // (the medication data routes stay live; this only hides the surface). { slug: "medications", titleKey: "settings.sections.medications.title", icon: Pill, + moduleGate: "medications", }, // v1.18.0 (S5) — Stimmung: the mood-tag management surface gets its own // nav entry, shown only when the mood module is enabled. Was a hidden diff --git a/src/components/settings/sources-section.tsx b/src/components/settings/sources-section.tsx index e490ae727..d9ce95c3d 100644 --- a/src/components/settings/sources-section.tsx +++ b/src/components/settings/sources-section.tsx @@ -126,11 +126,11 @@ export interface SourcesSectionProps { /** * `` — the per-metric source-priority + device-type ladders. * - * v1.18.0 (S3) — Sources folded into Settings → Integrations as the "Sources" - * sub-tab (`variant="subtab"`), since deciding which connection wins when two - * report the same metric is an Integrations concern. The standalone - * `/settings/sources` route is gone; `/settings/sources` 301-redirects to - * `/settings/integrations` (see `next.config.ts`). + * v1.18.1 (D4) — Sources is a standalone `/settings/sources` left-side entry + * again (split back out of the Integrations sub-tabs). The default + * `variant="standalone"` renders the section header + the cross-link back to + * Integrationen; `variant="subtab"` is retained for any embedded use and + * suppresses the header. */ export function SourcesSection({ variant = "standalone" }: SourcesSectionProps = {}) { const { t } = useTranslations(); @@ -298,23 +298,15 @@ export function SourcesSection({ variant = "standalone" }: SourcesSectionProps = aria-labelledby="settings-section-sources-title" className="space-y-6" > - {isSubtab ? ( - // Sub-tab mode: the Integrations section owns the page header, and a - // cross-link back to the page that already contains this tab would be - // circular. Surface only the one-line description. -

    - {t("settings.sections.sources.description")} -

    - ) : ( -
    + {isSubtab ? null : ( + // v1.18.1 (D0/D4) — standalone left-side entry again. The section + // blurb is dropped for consistent top alignment; the cross-link back + // to Integrationen (where the sources this ladder ranks are added / + // removed) stays as a quiet pointer. +

    {t("settings.sections.sources.title")}

    -

    - {t("settings.sections.sources.description")} -

    - {/* Cross-link back to Settings → Integrations — the page that - actually adds / removes the sources this ladder ranks. */}

    {t("settings.sections.sources.integrationsHint")}{" "} -

    +

    {t("settings.sections.thresholds.title")}

    -

    - {t("settings.sections.thresholds.description")} -

    {/* Per-metric threshold ranges. */} diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts index 13ee7c246..727551635 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -65,7 +65,7 @@ vi.mock("@/lib/modules/gate", () => ({ resolveModuleMap: (...a: unknown[]) => resolveModuleMap(...a), })); -/** All eleven toggleable keys on, with optional per-key overrides. */ +/** Every toggleable key on, with optional per-key overrides. */ function moduleMap( overrides: Partial> = {}, ): Record { @@ -81,6 +81,7 @@ function moduleMap( achievements: true, coach: true, insights: true, + medications: true, doctorReport: true, }; return { ...base, ...overrides }; @@ -810,6 +811,8 @@ describe("module gating maps — every toggleable-domain widget is mapped (v1.18 "glucose", "achievements", "recentWorkouts", + // v1.18.1 (D3) — medications graduated from CORE to a toggleable module. + "medications", "cardioRecovery", "sixMinuteWalk", "stairAscentSpeed", diff --git a/src/lib/dashboard/widget-modules.ts b/src/lib/dashboard/widget-modules.ts index 80af8e63b..99468d6d5 100644 --- a/src/lib/dashboard/widget-modules.ts +++ b/src/lib/dashboard/widget-modules.ts @@ -21,8 +21,11 @@ import type { ModuleKey } from "@/lib/modules/registry"; * module, the matching widget is forced invisible on both the web `layout` * and the iOS `layoutCatalogue`, so the tile/chart never paints. Only the * toggleable surfaces appear here; CORE widgets (weight / bp / pulse / - * bodyFat / medications / bpInTarget and the vital-derived HealthKit - * metrics) carry NO entry and are never hidden. + * bodyFat / bpInTarget and the vital-derived HealthKit metrics) carry NO + * entry and are never hidden. + * + * v1.18.1 (D3) — `medications` graduated from CORE to a toggleable module, + * so the medication tile now gates off it like the other toggleable widgets. */ export const WIDGET_MODULE_BY_ID: Partial> = { mood: "mood", @@ -30,6 +33,7 @@ export const WIDGET_MODULE_BY_ID: Partial> = { glucose: "glucose", achievements: "achievements", recentWorkouts: "workouts", + medications: "medications", // v1.18.0 B1 — recovery-domain HealthKit widgets belong to the recovery // module; the per-night breathing-disturbance widget belongs to sleep. cardioRecovery: "recovery", diff --git a/src/lib/modules/__tests__/gate.test.ts b/src/lib/modules/__tests__/gate.test.ts index 422e09589..b4f72b2cf 100644 --- a/src/lib/modules/__tests__/gate.test.ts +++ b/src/lib/modules/__tests__/gate.test.ts @@ -318,7 +318,7 @@ describe("normalisePrefs — fail-open coercion", () => { }); describe("registry — core domains can never be disabled", () => { - it("the four core domains are not toggleable module keys", () => { + it("the three core domains are not toggleable module keys", () => { for (const core of CORE_DOMAIN_KEYS) { expect(isModuleKey(core)).toBe(false); expect(isCoreDomain(core)).toBe(true); @@ -327,10 +327,11 @@ describe("registry — core domains can never be disabled", () => { it("a crafted core-domain key is inert (never read as a module)", () => { // `weight: false` in the blob can't matter: weight is not a ModuleKey, - // so the gate never consults it. The resolver only ever runs for the - // 11 declared toggleable keys. + // so the gate never consults it. The resolver only runs for the declared + // toggleable keys. v1.18.1 (D3) — medications graduated to a toggleable + // module, so it IS a ModuleKey now (weight / bp / pulse stay core). expect(isModuleKey("weight")).toBe(false); - expect(isModuleKey("medications")).toBe(false); + expect(isModuleKey("medications")).toBe(true); }); it("every toggleable key is a known module and not a core domain", () => { diff --git a/src/lib/modules/registry.ts b/src/lib/modules/registry.ts index 8e21fd20b..1cf57e8a7 100644 --- a/src/lib/modules/registry.ts +++ b/src/lib/modules/registry.ts @@ -103,6 +103,15 @@ export const MODULE_KEYS = [ "achievements", "coach", "insights", + // v1.18.1 (D3) — medications graduated from the always-on CORE set to a + // toggleable module. Weight / blood pressure / pulse stay CORE (the + // measurement engine is never disableable); medications is now an opt-out + // domain so an account that does not track meds can hide the surface. + // SURFACE-gated (nav entry, dashboard medication widget, the dedicated + // Medikamente settings entry) — the medication data-layer routes stay + // exempt like mood/labs so an importer / sync / cleanup keeps working and + // re-enabling finds the rows intact. + "medications", "doctorReport", ] as const; @@ -119,7 +128,6 @@ export const CORE_DOMAIN_KEYS = [ "weight", "bloodPressure", "pulse", - "medications", ] as const; export type CoreDomainKey = (typeof CORE_DOMAIN_KEYS)[number]; @@ -232,6 +240,12 @@ export const MODULE_REGISTRY: Readonly> = descriptionKey: "modules.insights.description", category: "intelligence", }, + medications: { + key: "medications", + labelKey: "modules.medications.label", + descriptionKey: "modules.medications.description", + category: "tracking", + }, doctorReport: { key: "doctorReport", labelKey: "modules.doctorReport.label", diff --git a/src/lib/validations/modules.ts b/src/lib/validations/modules.ts index d33ae6870..05c041870 100644 --- a/src/lib/validations/modules.ts +++ b/src/lib/validations/modules.ts @@ -3,10 +3,11 @@ * * The PATCH body is a partial map of TOGGLEABLE module keys → boolean. * Only keys in the canonical registry (`MODULE_KEYS`) are accepted; a - * core-domain key (`weight`, `bloodPressure`, `pulse`, `medications`) or - * any unknown key is a 422 — so the core measurement engine + meds can - * never be disabled through this surface (defence-in-depth on top of the - * gate, which has no key to flip for core domains anyway). + * core-domain key (`weight`, `bloodPressure`, `pulse`) or any unknown key + * is a 422 — so the core measurement engine can never be disabled through + * this surface (defence-in-depth on top of the gate, which has no key to + * flip for core domains anyway). v1.18.1 (D3) — `medications` graduated to + * a toggleable module, so it IS writable here now. * * `strict()` rejects unknown keys outright rather than silently dropping * them, so a client gets a clear error instead of a no-op. diff --git a/tests/integration/doctor-report-sections.test.ts b/tests/integration/doctor-report-sections.test.ts index af952660f..65df1f589 100644 --- a/tests/integration/doctor-report-sections.test.ts +++ b/tests/integration/doctor-report-sections.test.ts @@ -315,6 +315,7 @@ describe("doctor-report — module enable/disable gating", () => { achievements: true, coach: true, insights: true, + medications: true, doctorReport: true, } as const;