mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
feat(settings): round-2 information-architecture rework
Apply the v1.18.0 walkthrough notes across the settings surface: - Drop the explanatory top-text block from every settings section so content starts at one consistent vertical height (the sr-only heading stays for the accessibility tree). - Move "Restart onboarding tour" out of Account into Advanced. - Module hub: drop the explanatory line above the first card. - Split the Integrations page into three left-side entries — the Connections content is now the Integrationen page itself, with Channels and Sources (source weighting) as their own entries. Drop the /settings/sources redirect (it is a real route again); Channels is a new entry. - Notifications: drop the top text, the channels / inbox cross-links, and the embedded Vorsorge editor (it has its own page). Keep the mood, low-stock, and proactive-coach reminder cards. - Rename the Layout hub entry to "Layout". - Flip the Coach toggle to "Activate Coach" (default on, opt-out) over the same single disableCoach column — one source of truth. - Move the AI "About me" context into Account → Profil, before cycle tracking. - Gesundheitsakte: replace the gradient hero with a normal card. i18n across all six locales for the renamed / added labels; settings tests updated for the new structure.
This commit is contained in:
1 parent
559366e308
commit
9b191a208d
41 files changed
+478
-407
No files matched your search
+17
-3
@@ -3893,7 +3893,7 @@
|
||||
"inboxLink": "Posteingang öffnen"
|
||||
},
|
||||
"layout": {
|
||||
"title": "Layout & Personalisierung",
|
||||
"title": "Layout",
|
||||
"description": "Lege fest, wie Dashboard und Insights aufgebaut und angeordnet sind.",
|
||||
"backToHub": "Zurück zu Layout & Personalisierung",
|
||||
"dashboard": {
|
||||
@@ -4147,6 +4147,10 @@
|
||||
"coach": {
|
||||
"title": "Coach",
|
||||
"description": "Coach aktivieren, Ton und Umfang, gespeicherte Erinnerungen."
|
||||
},
|
||||
"channels": {
|
||||
"title": "Kanäle",
|
||||
"description": "Zustellung über Telegram, ntfy, Web-Push, Webhook und E-Mail."
|
||||
}
|
||||
},
|
||||
"profile": "Profil",
|
||||
@@ -4858,7 +4862,17 @@
|
||||
"rate_limited": "Zu viele Verbindungsversuche. Warte einen Moment und versuche es erneut.",
|
||||
"generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut."
|
||||
},
|
||||
"integrationSetupGuide": "Einrichtungsanleitung"
|
||||
"integrationSetupGuide": "Einrichtungsanleitung",
|
||||
"coach": {
|
||||
"activate": {
|
||||
"title": "Coach aktivieren",
|
||||
"description": "Zeigt den Coach-Button und das Coach-Panel. Standardmäßig an — ausschalten, um den Coach überall auszublenden.",
|
||||
"toggleAria": "Coach-Button und Coach-Panel aktivieren",
|
||||
"savedOn": "Coach ist aktiviert.",
|
||||
"savedOff": "Coach ist ausgeblendet.",
|
||||
"saveError": "Speichern fehlgeschlagen. Bitte erneut versuchen."
|
||||
}
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
@@ -6713,7 +6727,7 @@
|
||||
"description": "KI-Chat mit Bezug zu deinen Daten."
|
||||
},
|
||||
"insights": {
|
||||
"label": "Auswertungen",
|
||||
"label": "Insights",
|
||||
"description": "KI-Analysen, Briefing und Korrelationen."
|
||||
},
|
||||
"doctorReport": {
|
||||
|
||||
+16
-2
@@ -3893,7 +3893,7 @@
|
||||
"inboxLink": "Open the inbox"
|
||||
},
|
||||
"layout": {
|
||||
"title": "Layout & Personalization",
|
||||
"title": "Layout",
|
||||
"description": "Arrange how the dashboard and insights are laid out.",
|
||||
"backToHub": "Back to Layout & Personalization",
|
||||
"dashboard": {
|
||||
@@ -4147,6 +4147,10 @@
|
||||
"coach": {
|
||||
"title": "Coach",
|
||||
"description": "Enable the Coach, tone and scope, stored memories."
|
||||
},
|
||||
"channels": {
|
||||
"title": "Channels",
|
||||
"description": "Telegram, ntfy, Web Push, Webhook, and Email delivery."
|
||||
}
|
||||
},
|
||||
"profile": "Profile",
|
||||
@@ -4858,7 +4862,17 @@
|
||||
"rate_limited": "Too many connection attempts. Wait a moment and try again.",
|
||||
"generic": "Unknown error while connecting. Please start the connection again."
|
||||
},
|
||||
"integrationSetupGuide": "Setup guide"
|
||||
"integrationSetupGuide": "Setup guide",
|
||||
"coach": {
|
||||
"activate": {
|
||||
"title": "Activate Coach",
|
||||
"description": "Show the Coach button and drawer. On by default — turn it off to hide the Coach everywhere.",
|
||||
"toggleAria": "Activate the Coach button and drawer",
|
||||
"savedOn": "Coach activated.",
|
||||
"savedOff": "Coach hidden.",
|
||||
"saveError": "Could not save the change. Please try again."
|
||||
}
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
|
||||
+17
-3
@@ -3893,7 +3893,7 @@
|
||||
"inboxLink": "Abrir la bandeja de entrada"
|
||||
},
|
||||
"layout": {
|
||||
"title": "Diseño y personalización",
|
||||
"title": "Diseño",
|
||||
"description": "Organiza cómo están dispuestos el panel y los análisis.",
|
||||
"backToHub": "Volver a Diseño y personalización",
|
||||
"dashboard": {
|
||||
@@ -4147,6 +4147,10 @@
|
||||
"coach": {
|
||||
"title": "Coach",
|
||||
"description": "Activa el Coach, tono y alcance, recuerdos guardados."
|
||||
},
|
||||
"channels": {
|
||||
"title": "Canales",
|
||||
"description": "Envío por Telegram, ntfy, Web Push, Webhook y correo electrónico."
|
||||
}
|
||||
},
|
||||
"profile": "Perfil",
|
||||
@@ -4858,7 +4862,17 @@
|
||||
"rate_limited": "Demasiados intentos de conexión. Espera un momento e inténtalo de nuevo.",
|
||||
"generic": "Error desconocido al conectar. Inicia la conexión de nuevo."
|
||||
},
|
||||
"integrationSetupGuide": "Guía de configuración"
|
||||
"integrationSetupGuide": "Guía de configuración",
|
||||
"coach": {
|
||||
"activate": {
|
||||
"title": "Activar el Coach",
|
||||
"description": "Muestra el botón y el panel del Coach. Activado por defecto: desactívalo para ocultar el Coach en todas partes.",
|
||||
"toggleAria": "Activar el botón y el panel del Coach",
|
||||
"savedOn": "Coach activado.",
|
||||
"savedOff": "Coach oculto.",
|
||||
"saveError": "No se pudo guardar el cambio. Inténtalo de nuevo."
|
||||
}
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
@@ -6713,7 +6727,7 @@
|
||||
"description": "Chat de IA basado en tus propios datos."
|
||||
},
|
||||
"insights": {
|
||||
"label": "Análisis",
|
||||
"label": "Insights",
|
||||
"description": "Análisis de IA, resumen y correlaciones."
|
||||
},
|
||||
"doctorReport": {
|
||||
|
||||
+17
-3
@@ -3893,7 +3893,7 @@
|
||||
"inboxLink": "Ouvrir la boîte de réception"
|
||||
},
|
||||
"layout": {
|
||||
"title": "Disposition et personnalisation",
|
||||
"title": "Disposition",
|
||||
"description": "Organisez la disposition du tableau de bord et des analyses.",
|
||||
"backToHub": "Retour à Disposition et personnalisation",
|
||||
"dashboard": {
|
||||
@@ -4147,6 +4147,10 @@
|
||||
"coach": {
|
||||
"title": "Coach",
|
||||
"description": "Activez le Coach, le ton et la portée, les souvenirs enregistrés."
|
||||
},
|
||||
"channels": {
|
||||
"title": "Canaux",
|
||||
"description": "Distribution via Telegram, ntfy, Web Push, Webhook et e-mail."
|
||||
}
|
||||
},
|
||||
"profile": "Profil",
|
||||
@@ -4858,7 +4862,17 @@
|
||||
"rate_limited": "Trop de tentatives de connexion. Patientez un instant et réessayez.",
|
||||
"generic": "Erreur inconnue lors de la connexion. Veuillez relancer la connexion."
|
||||
},
|
||||
"integrationSetupGuide": "Guide de configuration"
|
||||
"integrationSetupGuide": "Guide de configuration",
|
||||
"coach": {
|
||||
"activate": {
|
||||
"title": "Activer le Coach",
|
||||
"description": "Affiche le bouton et le panneau du Coach. Activé par défaut — désactivez-le pour masquer le Coach partout.",
|
||||
"toggleAria": "Activer le bouton et le panneau du Coach",
|
||||
"savedOn": "Coach activé.",
|
||||
"savedOff": "Coach masqué.",
|
||||
"saveError": "Impossible d enregistrer la modification. Veuillez réessayer."
|
||||
}
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
@@ -6713,7 +6727,7 @@
|
||||
"description": "Chat IA fondé sur vos propres données."
|
||||
},
|
||||
"insights": {
|
||||
"label": "Analyses",
|
||||
"label": "Insights",
|
||||
"description": "Analyses IA, briefing et corrélations."
|
||||
},
|
||||
"doctorReport": {
|
||||
|
||||
+17
-3
@@ -3893,7 +3893,7 @@
|
||||
"inboxLink": "Apri la posta in arrivo"
|
||||
},
|
||||
"layout": {
|
||||
"title": "Layout e personalizzazione",
|
||||
"title": "Layout",
|
||||
"description": "Organizza la disposizione del cruscotto e degli approfondimenti.",
|
||||
"backToHub": "Torna a Layout e personalizzazione",
|
||||
"dashboard": {
|
||||
@@ -4147,6 +4147,10 @@
|
||||
"coach": {
|
||||
"title": "Coach",
|
||||
"description": "Attiva il Coach, tono e ambito, ricordi salvati."
|
||||
},
|
||||
"channels": {
|
||||
"title": "Canali",
|
||||
"description": "Recapito tramite Telegram, ntfy, Web Push, Webhook ed e-mail."
|
||||
}
|
||||
},
|
||||
"profile": "Profilo",
|
||||
@@ -4858,7 +4862,17 @@
|
||||
"rate_limited": "Troppi tentativi di connessione. Attendi un momento e riprova.",
|
||||
"generic": "Errore sconosciuto durante la connessione. Riavvia la connessione."
|
||||
},
|
||||
"integrationSetupGuide": "Guida alla configurazione"
|
||||
"integrationSetupGuide": "Guida alla configurazione",
|
||||
"coach": {
|
||||
"activate": {
|
||||
"title": "Attiva il Coach",
|
||||
"description": "Mostra il pulsante e il pannello del Coach. Attivo per impostazione predefinita: disattivalo per nascondere il Coach ovunque.",
|
||||
"toggleAria": "Attiva il pulsante e il pannello del Coach",
|
||||
"savedOn": "Coach attivato.",
|
||||
"savedOff": "Coach nascosto.",
|
||||
"saveError": "Impossibile salvare la modifica. Riprova."
|
||||
}
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
@@ -6713,7 +6727,7 @@
|
||||
"description": "Chat IA basata sui tuoi dati."
|
||||
},
|
||||
"insights": {
|
||||
"label": "Analisi",
|
||||
"label": "Insights",
|
||||
"description": "Analisi IA, briefing e correlazioni."
|
||||
},
|
||||
"doctorReport": {
|
||||
|
||||
+17
-3
@@ -3893,7 +3893,7 @@
|
||||
"inboxLink": "Otwórz skrzynkę odbiorczą"
|
||||
},
|
||||
"layout": {
|
||||
"title": "Układ i personalizacja",
|
||||
"title": "Układ",
|
||||
"description": "Ustal, jak rozłożone są pulpit i analizy.",
|
||||
"backToHub": "Powrót do Układ i personalizacja",
|
||||
"dashboard": {
|
||||
@@ -4147,6 +4147,10 @@
|
||||
"coach": {
|
||||
"title": "Coach",
|
||||
"description": "Włącz Coacha, ton i zakres, zapisane wspomnienia."
|
||||
},
|
||||
"channels": {
|
||||
"title": "Kanały",
|
||||
"description": "Dostarczanie przez Telegram, ntfy, Web Push, Webhook i e-mail."
|
||||
}
|
||||
},
|
||||
"profile": "Profil",
|
||||
@@ -4858,7 +4862,17 @@
|
||||
"rate_limited": "Zbyt wiele prób połączenia. Poczekaj chwilę i spróbuj ponownie.",
|
||||
"generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie."
|
||||
},
|
||||
"integrationSetupGuide": "Przewodnik konfiguracji"
|
||||
"integrationSetupGuide": "Przewodnik konfiguracji",
|
||||
"coach": {
|
||||
"activate": {
|
||||
"title": "Włącz Coacha",
|
||||
"description": "Pokazuje przycisk i panel Coacha. Domyślnie włączony — wyłącz, aby ukryć Coacha wszędzie.",
|
||||
"toggleAria": "Włącz przycisk i panel Coacha",
|
||||
"savedOn": "Coach włączony.",
|
||||
"savedOff": "Coach ukryty.",
|
||||
"saveError": "Nie udało się zapisać zmiany. Spróbuj ponownie."
|
||||
}
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administracja",
|
||||
@@ -6713,7 +6727,7 @@
|
||||
"description": "Czat AI oparty na Twoich danych."
|
||||
},
|
||||
"insights": {
|
||||
"label": "Analizy",
|
||||
"label": "Insights",
|
||||
"description": "Analizy AI, briefing i korelacje."
|
||||
},
|
||||
"doctorReport": {
|
||||
|
||||
+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
|
||||
|
||||
@@ -173,7 +173,9 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
|
||||
"src/app/api/medications/compliance/route.ts",
|
||||
"src/app/api/medications/intake/route.ts",
|
||||
"src/app/api/medications/intake/bulk/route.ts",
|
||||
"src/app/api/medications/extract/route.ts",
|
||||
// NB: `medications/extract` is NOT exempt — it gates on
|
||||
// `requireAssistantSurface("coach")` (the NL-extraction is an assistant
|
||||
// surface), so the inventory already counts it as a delegated gate.
|
||||
"src/app/api/medications/[id]/route.ts",
|
||||
"src/app/api/medications/[id]/api-endpoint/route.ts",
|
||||
"src/app/api/medications/[id]/cadence/route.ts",
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -233,13 +233,10 @@ export function AboutSection() {
|
||||
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
|
||||
|
||||
@@ -118,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.
|
||||
|
||||
@@ -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. */}
|
||||
|
||||
Reference in new issue
Block a user