mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
merge: settings IA round 2 — top-text removal, integration split, medications toggleable, coach activate-flip, gesundheitsakte de-hero
This commit is contained in:
commit
8447ad90cc
51 files changed
+596
-434
No files matched your search
@@ -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
|
||||
|
||||
+17
-3
@@ -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": {
|
||||
|
||||
+16
-2
@@ -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",
|
||||
|
||||
+17
-3
@@ -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": {
|
||||
|
||||
+17
-3
@@ -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": {
|
||||
|
||||
+17
-3
@@ -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": {
|
||||
|
||||
+17
-3
@@ -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": {
|
||||
|
||||
+3
-9
@@ -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
|
||||
|
||||
@@ -86,6 +86,17 @@ const MODULE_ROUTE_TREES: ReadonlyArray<string> = [
|
||||
// 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<string> = [
|
||||
"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,
|
||||
|
||||
@@ -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<Response>)(
|
||||
mkPatch({ [core]: false }),
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<NavDestination> = [
|
||||
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",
|
||||
|
||||
@@ -42,25 +42,26 @@ function render(locale: "en" | "de" = "en") {
|
||||
}
|
||||
|
||||
describe("<CoachSection> — 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.");
|
||||
});
|
||||
});
|
||||
@@ -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 `<Switch>` 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -80,12 +80,11 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("<MedicationsSection> — 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("<MedicationsSection> — 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.",
|
||||
|
||||
@@ -147,17 +147,17 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("<MoodSection> — 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");
|
||||
|
||||
@@ -76,9 +76,15 @@ describe("<NotificationsSection> 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"');
|
||||
});
|
||||
});
|
||||
@@ -119,17 +119,21 @@ describe("settings sections — SSR smoke", () => {
|
||||
expect(html).not.toContain("settings.sections.");
|
||||
});
|
||||
|
||||
it("<AccountSection> exposes a Restart-tour button for B5 replay", () => {
|
||||
const html = render(<AccountSection />);
|
||||
// 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("<AdvancedSection> 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(<AdvancedSection />);
|
||||
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("<AccountSection> no longer carries the Restart-tour button", () => {
|
||||
const html = render(<AccountSection />);
|
||||
expect(html).not.toContain('data-testid="settings-restart-tour"');
|
||||
});
|
||||
|
||||
it("<AboutSection> renders version + license + sources/docs cards (no Updates panel)", () => {
|
||||
const html = render(<AboutSection />);
|
||||
expect(html).toContain("About");
|
||||
@@ -184,17 +188,14 @@ describe("settings sections — SSR smoke", () => {
|
||||
|
||||
it("<NotificationsSection> renders the single reminder-types home", () => {
|
||||
const html = render(<NotificationsSection />);
|
||||
// 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"');
|
||||
|
||||
@@ -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("<SettingsShell>", () => {
|
||||
// `/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</a>");
|
||||
// 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("<SettingsShell>", () => {
|
||||
// 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("<SettingsShell>", () => {
|
||||
// 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</a>");
|
||||
// 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("<SettingsShell>", () => {
|
||||
// "Ü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", () => {
|
||||
|
||||
@@ -244,13 +244,10 @@ export function AboutSection({ hideTourReplay = false }: AboutSectionProps = {})
|
||||
aria-labelledby="settings-section-about-title"
|
||||
className="space-y-6"
|
||||
>
|
||||
<header className="space-y-1">
|
||||
<header>
|
||||
<h1 id="settings-section-about-title" className="sr-only">
|
||||
{t("settings.sections.about.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.sections.about.description")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Identity card — version + license inline. The v1.4.2 layout
|
||||
|
||||
@@ -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
|
||||
// <TourLauncher> 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 `<AboutSection>`'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<StatusMessage | null>(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"
|
||||
>
|
||||
<header className="space-y-1">
|
||||
{/* 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. */}
|
||||
<header>
|
||||
<h1 id="settings-section-account-title" className="sr-only">
|
||||
{t("settings.sections.account.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.sections.account.description")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* 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. */}
|
||||
<AboutMeSection isAuthenticated={isAuthenticated} />
|
||||
|
||||
{/* 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() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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. */}
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<Compass className="text-muted-foreground mt-0.5 h-5 w-5 shrink-0" />
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("onboarding.tour.restart")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("onboarding.tour.restartHint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleRestartTour}
|
||||
disabled={tourRestarting}
|
||||
data-testid="settings-restart-tour"
|
||||
className="w-full shrink-0 sm:w-auto"
|
||||
>
|
||||
{tourRestarting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Compass className="h-4 w-4" />
|
||||
)}
|
||||
{t("onboarding.tour.restart")}
|
||||
</Button>
|
||||
</div>
|
||||
{tourFeedback && (
|
||||
<p
|
||||
role="alert"
|
||||
className={`mt-2 text-xs ${
|
||||
tourFeedback.type === "success"
|
||||
? "text-success"
|
||||
: "text-destructive"
|
||||
}`}
|
||||
>
|
||||
{t(tourFeedback.key)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{/* 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. */}
|
||||
|
||||
<Dialog
|
||||
open={passwordDialogOpen}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { BookOpenCheck, Loader2, Trash2 } from "lucide-react";
|
||||
import { BookOpenCheck, Compass, Loader2, Trash2 } from "lucide-react";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -20,7 +20,9 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { ResearchModeAcknowledgmentDialog } from "@/components/medications/ResearchModeAcknowledgmentDialog";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { restartOnboardingTour } from "@/lib/onboarding/tour-restart";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
type ResearchModeStatus,
|
||||
@@ -56,15 +58,15 @@ export function AdvancedSection() {
|
||||
aria-labelledby="settings-section-advanced-title"
|
||||
className="space-y-6"
|
||||
>
|
||||
<header className="space-y-1">
|
||||
{/* v1.18.1 (D0) — section blurb dropped for consistent top alignment. */}
|
||||
<header>
|
||||
<h1 id="settings-section-advanced-title" className="sr-only">
|
||||
{t("settings.sections.advanced.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.sections.advanced.description")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* v1.18.1 (D1) — "Tour neu starten" relocated here from Account. */}
|
||||
<TourReplayCard />
|
||||
<ResearchModeCard />
|
||||
<DataResetCard />
|
||||
<AccountDeleteCard />
|
||||
@@ -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 (
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<Compass className="text-muted-foreground mt-0.5 h-5 w-5 shrink-0" />
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("onboarding.tour.restart")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("onboarding.tour.restartHint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleRestartTour}
|
||||
disabled={restarting}
|
||||
data-testid="settings-restart-tour"
|
||||
className="w-full shrink-0 sm:w-auto"
|
||||
>
|
||||
{restarting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Compass className="h-4 w-4" />
|
||||
)}
|
||||
{t("onboarding.tour.restart")}
|
||||
</Button>
|
||||
</div>
|
||||
{feedback && (
|
||||
<p
|
||||
role="alert"
|
||||
className={`mt-2 text-xs ${
|
||||
feedback.type === "success" ? "text-success" : "text-destructive"
|
||||
}`}
|
||||
>
|
||||
{t(feedback.key)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 /
|
||||
|
||||
@@ -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 (
|
||||
<section aria-labelledby="settings-section-ai-title" className="space-y-6">
|
||||
<header className="space-y-1">
|
||||
{/* v1.18.1 (D0) — section blurb dropped for consistent top alignment. */}
|
||||
<header>
|
||||
<h1 id="settings-section-ai-title" className="sr-only">
|
||||
{t("settings.sections.ai.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.sections.ai.description")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<AiInsightsCard isAuthenticated={authed} />
|
||||
|
||||
{/* 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. */}
|
||||
<AboutMeSection isAuthenticated={authed} />
|
||||
{/* v1.18.1 (D8) — the "About me" context moved to Settings → Account
|
||||
(under Profil, before Zyklus-Tracking). */}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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 (`<LayoutCoachFab>`, `<LayoutCoachMount>`, the inline
|
||||
* `<CoachLaunchButton>` 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 `<MoodReminderCard>`: 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
|
||||
* (`<LayoutCoachFab>`, `<LayoutCoachMount>`, the inline `<CoachLaunchButton>`
|
||||
* 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 `<MoodReminderCard>`: 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"
|
||||
>
|
||||
<SettingsCardHeader
|
||||
icon={MessageCircleOff}
|
||||
title={t("settings.ai.disableCoach.title")}
|
||||
icon={MessageCircleHeart}
|
||||
title={t("settings.coach.activate.title")}
|
||||
titleId="settings-ai-disable-coach-title"
|
||||
description={t("settings.ai.disableCoach.description")}
|
||||
description={t("settings.coach.activate.description")}
|
||||
status={
|
||||
<Switch
|
||||
data-testid="settings-disable-coach-switch"
|
||||
checked={checked}
|
||||
checked={activated}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={!isAuthenticated || mutation.isPending}
|
||||
aria-label={t("settings.ai.disableCoach.toggleAria")}
|
||||
aria-label={t("settings.coach.activate.toggleAria")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -41,13 +41,10 @@ export function ApiSection() {
|
||||
|
||||
return (
|
||||
<section aria-labelledby="settings-section-api-title" className="space-y-6">
|
||||
<header className="space-y-1">
|
||||
<header>
|
||||
<h1 id="settings-section-api-title" className="sr-only">
|
||||
{t("settings.sections.api.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.sections.api.description")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<ApiEndpointsCard />
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* `<ChannelsSection>` — 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 `<NotificationChannelsPanel>`; 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 (
|
||||
<section
|
||||
aria-labelledby="settings-section-channels-title"
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* v1.18.1 (D0) — section blurb dropped for consistent top alignment. */}
|
||||
<header>
|
||||
<h1 id="settings-section-channels-title" className="sr-only">
|
||||
{t("settings.sections.channels.title")}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<NotificationChannelsPanel />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -46,13 +46,11 @@ export function CoachSection() {
|
||||
aria-labelledby="settings-section-coach-title"
|
||||
className="space-y-6"
|
||||
>
|
||||
<header className="space-y-1">
|
||||
{/* v1.18.1 (D0) — section blurb dropped for consistent top alignment. */}
|
||||
<header>
|
||||
<h1 id="settings-section-coach-title" className="sr-only">
|
||||
{t("settings.sections.coach.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.sections.coach.description")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<DisableCoachCard isAuthenticated={authed} />
|
||||
|
||||
@@ -20,9 +20,7 @@ export function DashboardSection() {
|
||||
<h1 id="settings-section-dashboard-title" className="sr-only">
|
||||
{t("settings.sections.dashboard.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.sections.dashboard.description")}
|
||||
</p>
|
||||
{/* v1.18.1 (D0) — section blurb dropped for consistent top alignment. */}
|
||||
</header>
|
||||
|
||||
<DashboardLayoutSection id="dashboard-layout" />
|
||||
|
||||
@@ -75,13 +75,10 @@ export function ExportSection() {
|
||||
aria-labelledby="settings-section-export-title"
|
||||
className="space-y-6"
|
||||
>
|
||||
<header className="space-y-1">
|
||||
<header>
|
||||
<h1 id="settings-section-export-title" className="sr-only">
|
||||
{t("settings.sections.export.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.sections.export.description")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* v1.18.0 (S5) — the full health-record export moved to its own
|
||||
|
||||
@@ -27,16 +27,14 @@ export function GesundheitsakteSection() {
|
||||
aria-labelledby="settings-section-gesundheitsakte-title"
|
||||
className="space-y-6"
|
||||
>
|
||||
<header className="space-y-1">
|
||||
{/* v1.18.1 (D0) — section blurb dropped for consistent top alignment. */}
|
||||
<header>
|
||||
<h1
|
||||
id="settings-section-gesundheitsakte-title"
|
||||
className="sr-only"
|
||||
>
|
||||
{t("settings.sections.gesundheitsakte.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.sections.gesundheitsakte.description")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<HealthRecordExportPanel />
|
||||
|
||||
@@ -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
|
||||
// `<HeroStrip>` 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",
|
||||
)}
|
||||
>
|
||||
<SettingsCardHeader
|
||||
|
||||
@@ -32,9 +32,7 @@ export function InsightsSection() {
|
||||
<h1 id="settings-section-insights-title" className="sr-only">
|
||||
{t("settings.sections.insights.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.sections.insights.description")}
|
||||
</p>
|
||||
{/* v1.18.1 (D0) — section blurb dropped for consistent top alignment. */}
|
||||
</header>
|
||||
|
||||
<InsightsOverviewArrangeSection id="insights-overview-arrange" />
|
||||
|
||||
@@ -1,31 +1,21 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* `<IntegrationsSection>` — Settings → Integrations.
|
||||
* `<IntegrationsSection>` — 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<IntegrationsTab>("connections");
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="settings-section-integrations-title"
|
||||
className="space-y-6"
|
||||
>
|
||||
<header className="space-y-1">
|
||||
{/* v1.18.1 (D0) — section blurb dropped for consistent top alignment. */}
|
||||
<header>
|
||||
<h1 id="settings-section-integrations-title" className="sr-only">
|
||||
{t("settings.sections.integrations.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.sections.integrations.description")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(value) => setTab(value as IntegrationsTab)}
|
||||
>
|
||||
<TabsList className="w-full sm:w-fit">
|
||||
<TabsTrigger value="connections">
|
||||
{t("settings.sections.integrations.tabs.connections")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="channels">
|
||||
{t("settings.sections.integrations.tabs.channels")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="sources">
|
||||
{t("settings.sections.integrations.tabs.sources")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="connections">
|
||||
<ConnectionsPanel />
|
||||
</TabsContent>
|
||||
<TabsContent value="channels">
|
||||
<NotificationChannelsPanel />
|
||||
</TabsContent>
|
||||
<TabsContent value="sources">
|
||||
<SourcesSection variant="subtab" />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<ConnectionsPanel />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -58,13 +58,10 @@ export function LayoutSection() {
|
||||
aria-labelledby="settings-section-layout-title"
|
||||
className="space-y-6"
|
||||
>
|
||||
<header className="space-y-1">
|
||||
<header>
|
||||
<h1 id="settings-section-layout-title" className="sr-only">
|
||||
{t("settings.sections.layout.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.sections.layout.description")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<ul className="space-y-3">
|
||||
|
||||
@@ -76,13 +76,10 @@ export function MedicationsSection() {
|
||||
aria-labelledby="settings-section-medications-title"
|
||||
className="space-y-6"
|
||||
>
|
||||
<header className="space-y-1">
|
||||
<header>
|
||||
<h1 id="settings-section-medications-title" className="sr-only">
|
||||
{t("settings.sections.medications.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.sections.medications.description")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* View preference — cards vs table. The shared header toggle
|
||||
|
||||
@@ -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<ModuleKey, LucideIcon> = {
|
||||
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<CoreDomainKey, LucideIcon> = {
|
||||
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"
|
||||
>
|
||||
<header className="space-y-1">
|
||||
{/* v1.18.1 (D0) — section blurb dropped for consistent top alignment. */}
|
||||
<header>
|
||||
<h1 id="settings-section-modules-title" className="sr-only">
|
||||
{t("settings.sections.modules.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.sections.modules.description")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Toggleable modules */}
|
||||
{/* Toggleable modules. v1.18.1 (D2) — the explanatory line above the
|
||||
first card dropped; the per-row labels carry the meaning. */}
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={Blocks}
|
||||
title={t("settings.sections.modules.toggleable.title")}
|
||||
description={t("settings.sections.modules.toggleable.description")}
|
||||
className="mb-2"
|
||||
/>
|
||||
<div className="divide-border divide-y pl-7">
|
||||
|
||||
@@ -45,13 +45,10 @@ export function MoodSection() {
|
||||
aria-labelledby="settings-section-mood-title"
|
||||
className="space-y-6"
|
||||
>
|
||||
<header className="space-y-1">
|
||||
<header>
|
||||
<h1 id="settings-section-mood-title" className="sr-only">
|
||||
{t("settings.sections.mood.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.sections.mood.description")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Groups — picker group order + own groups. */}
|
||||
|
||||
@@ -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";
|
||||
|
||||
/**
|
||||
* `<NotificationsSection>` — 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"
|
||||
>
|
||||
<header className="space-y-1">
|
||||
{/* 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. */}
|
||||
<header>
|
||||
<h1 id="settings-section-notifications-title" className="sr-only">
|
||||
{t("settings.sections.notifications.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.sections.notifications.description")}
|
||||
</p>
|
||||
{/* 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. */}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.sections.notifications.channelsHint")}{" "}
|
||||
<Link
|
||||
href="/settings/integrations"
|
||||
className="text-primary underline underline-offset-2"
|
||||
data-slot="notifications-channels-cross-link"
|
||||
>
|
||||
{t("settings.sections.notifications.channelsHintLink")}
|
||||
</Link>
|
||||
{" · "}
|
||||
<Link
|
||||
href="/notifications"
|
||||
className="text-primary underline underline-offset-2"
|
||||
data-slot="notifications-inbox-cross-link"
|
||||
>
|
||||
{t("settings.sections.notifications.inboxLink")}
|
||||
</Link>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* 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. */}
|
||||
<VorsorgeSection enabled={isAuthenticated} />
|
||||
{/* 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 ? (
|
||||
|
||||
@@ -19,9 +19,11 @@ import {
|
||||
FileHeart,
|
||||
Info,
|
||||
KeyRound,
|
||||
Layers,
|
||||
LayoutDashboard,
|
||||
Link2,
|
||||
Pill,
|
||||
Radio,
|
||||
Settings2,
|
||||
SlidersHorizontal,
|
||||
Smile,
|
||||
@@ -39,6 +41,8 @@ const SLUG_ICON: Record<SettingsSectionSlug, LucideIcon> = {
|
||||
account: User,
|
||||
modules: Blocks,
|
||||
integrations: Link2,
|
||||
channels: Radio,
|
||||
sources: Layers,
|
||||
notifications: Bell,
|
||||
layout: LayoutDashboard,
|
||||
dashboard: LayoutDashboard,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -126,11 +126,11 @@ export interface SourcesSectionProps {
|
||||
/**
|
||||
* `<SourcesSection>` — 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.
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.sections.sources.description")}
|
||||
</p>
|
||||
) : (
|
||||
<header className="space-y-1">
|
||||
{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.
|
||||
<header>
|
||||
<h1 id="settings-section-sources-title" className="sr-only">
|
||||
{t("settings.sections.sources.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.sections.sources.description")}
|
||||
</p>
|
||||
{/* Cross-link back to Settings → Integrations — the page that
|
||||
actually adds / removes the sources this ladder ranks. */}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.sections.sources.integrationsHint")}{" "}
|
||||
<Link
|
||||
|
||||
@@ -38,13 +38,10 @@ export function ThresholdsSection() {
|
||||
aria-labelledby="settings-section-thresholds-title"
|
||||
className="space-y-6"
|
||||
>
|
||||
<header className="space-y-1">
|
||||
<header>
|
||||
<h1 id="settings-section-thresholds-title" className="sr-only">
|
||||
{t("settings.sections.thresholds.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.sections.thresholds.description")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Per-metric threshold ranges. */}
|
||||
|
||||
@@ -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<ModuleKey, boolean>> = {},
|
||||
): Record<ModuleKey, boolean> {
|
||||
@@ -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",
|
||||
|
||||
@@ -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<Record<string, ModuleKey>> = {
|
||||
mood: "mood",
|
||||
@@ -30,6 +33,7 @@ export const WIDGET_MODULE_BY_ID: Partial<Record<string, ModuleKey>> = {
|
||||
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",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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<Record<ModuleKey, ModuleDefinition>> =
|
||||
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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -315,6 +315,7 @@ describe("doctor-report — module enable/disable gating", () => {
|
||||
achievements: true,
|
||||
coach: true,
|
||||
insights: true,
|
||||
medications: true,
|
||||
doctorReport: true,
|
||||
} as const;
|
||||
|
||||
|
||||
Reference in new issue
Block a user