From 559366e308e1ef4780070958e91f06dd076a6f6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 16 Jun 2026 21:31:54 +0200 Subject: [PATCH 1/2] feat(settings): make medications a toggleable module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Medications graduated from the always-on core set (weight / blood pressure / pulse stay core) to an opt-out module. The surface gates off the resolved module map — the left-nav entry, the dashboard medication widget, and the dedicated Medikamente settings entry hide when the account turns the module off. The medication data routes stay exempt from the gate (same data-layer reasoning as mood / labs): an importer, a sync, and the user's ability to clean up entries keep working while the surface is hidden, and re-enabling finds the schedule and intake history intact. Wire the module-route gate inventory, the dashboard widget/summary maps, the PATCH allowlist, and the nav model; regenerate the OpenAPI contract for the new module key. --- docs/api/openapi.yaml | 6 +++ .../module-route-gate-inventory.test.ts | 46 +++++++++++++++++++ src/app/api/auth/me/modules/route.ts | 9 ++-- .../layout/__tests__/nav-model.test.ts | 10 +++- src/components/layout/nav-model.ts | 7 ++- src/components/settings/modules-section.tsx | 14 +++--- src/components/settings/settings-shell.tsx | 7 ++- src/lib/dashboard/__tests__/snapshot.test.ts | 5 +- src/lib/dashboard/widget-modules.ts | 8 +++- src/lib/modules/__tests__/gate.test.ts | 9 ++-- src/lib/modules/registry.ts | 16 ++++++- src/lib/validations/modules.ts | 9 ++-- .../doctor-report-sections.test.ts | 1 + 13 files changed, 119 insertions(+), 28 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 0e55644f1..71de69b3f 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -7076,6 +7076,8 @@ components: type: boolean insights: type: boolean + medications: + type: boolean doctorReport: type: boolean additionalProperties: false @@ -12730,6 +12732,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. @@ -12745,6 +12750,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/src/app/api/__tests__/module-route-gate-inventory.test.ts b/src/app/api/__tests__/module-route-gate-inventory.test.ts index 7d1629fa6..f909433eb 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,40 @@ 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", + "src/app/api/medications/extract/route.ts", + "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 +326,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/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/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/modules-section.tsx b/src/components/settings/modules-section.tsx index 67ff12410..12939a8af 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() { diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx index 9e773d6eb..d8a247f4a 100644 --- a/src/components/settings/settings-shell.tsx +++ b/src/components/settings/settings-shell.tsx @@ -124,12 +124,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/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; From 9b191a208d1c3b6b902f049bfb5cf9662a1aac80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 16 Jun 2026 21:47:00 +0200 Subject: [PATCH 2/2] feat(settings): round-2 information-architecture rework MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply the v1.18.0 walkthrough notes across the settings surface: - Drop the explanatory top-text block from every settings section so content starts at one consistent vertical height (the sr-only heading stays for the accessibility tree). - Move "Restart onboarding tour" out of Account into Advanced. - Module hub: drop the explanatory line above the first card. - Split the Integrations page into three left-side entries — the Connections content is now the Integrationen page itself, with Channels and Sources (source weighting) as their own entries. Drop the /settings/sources redirect (it is a real route again); Channels is a new entry. - Notifications: drop the top text, the channels / inbox cross-links, and the embedded Vorsorge editor (it has its own page). Keep the mood, low-stock, and proactive-coach reminder cards. - Rename the Layout hub entry to "Layout". - Flip the Coach toggle to "Activate Coach" (default on, opt-out) over the same single disableCoach column — one source of truth. - Move the AI "About me" context into Account → Profil, before cycle tracking. - Gesundheitsakte: replace the gradient hero with a normal card. i18n across all six locales for the renamed / added labels; settings tests updated for the new structure. --- messages/de.json | 20 +++- messages/en.json | 18 ++- messages/es.json | 20 +++- messages/fr.json | 20 +++- messages/it.json | 20 +++- messages/pl.json | 20 +++- next.config.ts | 12 +- .../module-route-gate-inventory.test.ts | 4 +- .../auth/me/modules/__tests__/route.test.ts | 4 +- src/app/settings/[section]/page.tsx | 9 +- .../settings/__tests__/coach-section.test.tsx | 17 +-- .../__tests__/disable-coach-card.test.tsx | 37 +++--- .../__tests__/medications-section.test.tsx | 10 +- .../settings/__tests__/mood-section.test.tsx | 6 +- ...tifications-section-module-gating.test.tsx | 12 +- .../settings/__tests__/sections.test.tsx | 27 ++--- .../__tests__/settings-shell.test.tsx | 37 +++--- src/components/settings/about-section.tsx | 5 +- src/components/settings/account-section.tsx | 108 +++--------------- src/components/settings/advanced-section.tsx | 91 ++++++++++++++- src/components/settings/ai-section.tsx | 22 ++-- .../settings/ai/disable-coach-card.tsx | 65 ++++++----- src/components/settings/api-section.tsx | 5 +- src/components/settings/channels-section.tsx | 37 ++++++ src/components/settings/coach-section.tsx | 6 +- src/components/settings/dashboard-section.tsx | 4 +- src/components/settings/export-section.tsx | 5 +- .../settings/gesundheitsakte-section.tsx | 6 +- .../settings/health-record-export-panel.tsx | 20 ++-- src/components/settings/insights-section.tsx | 4 +- .../settings/integrations-section.tsx | 62 ++-------- src/components/settings/layout-section.tsx | 5 +- .../settings/medications-section.tsx | 5 +- src/components/settings/modules-section.tsx | 10 +- src/components/settings/mood-section.tsx | 5 +- .../settings/notifications-section.tsx | 57 +++------ .../settings/section-placeholder.tsx | 4 + src/components/settings/section-slugs.ts | 15 ++- src/components/settings/settings-shell.tsx | 16 +++ src/components/settings/sources-section.tsx | 30 ++--- .../settings/thresholds-section.tsx | 5 +- 41 files changed, 478 insertions(+), 407 deletions(-) create mode 100644 src/components/settings/channels-section.tsx diff --git a/messages/de.json b/messages/de.json index 0a4f06172..d076afba0 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3893,7 +3893,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": { @@ -4147,6 +4147,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", @@ -4858,7 +4862,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", @@ -6713,7 +6727,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 a9fcbab85..efb9512b5 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3893,7 +3893,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": { @@ -4147,6 +4147,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", @@ -4858,7 +4862,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 e7e8e81e2..1ba0143b3 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3893,7 +3893,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": { @@ -4147,6 +4147,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", @@ -4858,7 +4862,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", @@ -6713,7 +6727,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 1320cc4c2..2b4b4f99e 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3893,7 +3893,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": { @@ -4147,6 +4147,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", @@ -4858,7 +4862,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", @@ -6713,7 +6727,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 a643e7671..83e53e007 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3893,7 +3893,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": { @@ -4147,6 +4147,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", @@ -4858,7 +4862,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", @@ -6713,7 +6727,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 0b1792274..9842f8d7b 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3893,7 +3893,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": { @@ -4147,6 +4147,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", @@ -4858,7 +4862,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", @@ -6713,7 +6727,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 f909433eb..57a5daca1 100644 --- a/src/app/api/__tests__/module-route-gate-inventory.test.ts +++ b/src/app/api/__tests__/module-route-gate-inventory.test.ts @@ -173,7 +173,9 @@ const EXEMPT_ROUTES: ReadonlyArray = [ "src/app/api/medications/compliance/route.ts", "src/app/api/medications/intake/route.ts", "src/app/api/medications/intake/bulk/route.ts", - "src/app/api/medications/extract/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", 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/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/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 cfbaca8d0..508aa95ed 100644 --- a/src/components/settings/about-section.tsx +++ b/src/components/settings/about-section.tsx @@ -233,13 +233,10 @@ export function AboutSection() { 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 12939a8af..95f1ae25b 100644 --- a/src/components/settings/modules-section.tsx +++ b/src/components/settings/modules-section.tsx @@ -118,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 d8a247f4a..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. 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. */}