From 79ce836335373272534eb94741e8df718129ded7 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 6 Jun 2026 23:14:04 +0200
Subject: [PATCH 01/11] fix(settings): guard notification panel against
non-array status payload
The notifications settings panel white-screened with "x.map is not a
function" when a status payload reached the card as the whole
{ channels, events } object instead of the channels array (events is an
object map, not an array). Guard the channel map with an Array.isArray
check, default the mood reminder-hour read to optional chaining, and
add a regression test that renders the empty state instead of crashing
on a malformed payload.
---
.../medications/sections/notifications-section.tsx | 2 +-
.../__tests__/notification-status-card.test.tsx | 14 ++++++++++++++
src/components/settings/mood-reminder-card.tsx | 2 +-
.../settings/notification-status-card.tsx | 7 ++++++-
4 files changed, 22 insertions(+), 3 deletions(-)
diff --git a/src/components/medications/sections/notifications-section.tsx b/src/components/medications/sections/notifications-section.tsx
index d4a089453..07d2eccff 100644
--- a/src/components/medications/sections/notifications-section.tsx
+++ b/src/components/medications/sections/notifications-section.tsx
@@ -201,7 +201,7 @@ export function NotificationsBody({
)}
- {status && status.channels.length > 0 && (
+ {Array.isArray(status?.channels) && status.channels.length > 0 && (
", () => {
expect(html).not.toContain('data-testid="notification-status-list"');
});
+ it("renders empty state instead of crashing when data is not an array", () => {
+ // Regression: the status endpoint returns `{ channels, events }` where
+ // `events` is an object map. A shape-drifted / poisoned cache read that
+ // hands the whole object to the card must not reach `.map` on a
+ // non-array and white-screen the notifications panel.
+ useQueryMock.mockReturnValue({
+ data: { channels: [], events: {} } as unknown as MockChannel[],
+ isLoading: false,
+ });
+ const html = render();
+ expect(html).toContain("No channels configured yet");
+ expect(html).not.toContain('data-testid="notification-status-list"');
+ });
+
it("paints Active badge for a healthy channel", () => {
setChannels([
{
diff --git a/src/components/settings/mood-reminder-card.tsx b/src/components/settings/mood-reminder-card.tsx
index 7d1340400..d4d7863dd 100644
--- a/src/components/settings/mood-reminder-card.tsx
+++ b/src/components/settings/mood-reminder-card.tsx
@@ -84,7 +84,7 @@ export function MoodReminderCard({
});
const enabled = optimistic ?? profile?.moodReminderEnabled ?? false;
- const reminderHour = optimisticHour ?? prefs?.mood.reminderHour ?? 22;
+ const reminderHour = optimisticHour ?? prefs?.mood?.reminderHour ?? 22;
async function handleToggle(next: boolean) {
setOptimistic(next);
diff --git a/src/components/settings/notification-status-card.tsx b/src/components/settings/notification-status-card.tsx
index fca8276c1..b0609ac8e 100644
--- a/src/components/settings/notification-status-card.tsx
+++ b/src/components/settings/notification-status-card.tsx
@@ -119,7 +119,12 @@ export function NotificationStatusCard() {
);
}
- const channels = data ?? [];
+ // Defensive: the status endpoint returns `{ channels, events }` where
+ // `events` is an object map, not an array. A stale or shape-drifted
+ // payload (or a cache read that kept the whole object instead of the
+ // `.channels` slice) must never reach `.map` on a non-array — guard the
+ // type here rather than white-screening the whole notifications panel.
+ const channels = Array.isArray(data) ? data : [];
if (channels.length === 0) {
return (
Date: Sat, 6 Jun 2026 23:14:34 +0200
Subject: [PATCH 02/11] feat(settings): drop the moodLog integration from the
integrations page
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Remove the moodLog card, its render, and the moodLog-only UI plumbing
from the integrations settings — the integration is no longer offered.
The backing columns, routes, and status aggregator entry stay in place
for a later dedicated removal. Also shorten the integration save button
from "Save credentials" to "Save" across all locales, and add the
nav account-settings label.
---
messages/de.json | 7 +-
messages/en.json | 7 +-
messages/es.json | 7 +-
messages/fr.json | 7 +-
messages/it.json | 7 +-
messages/pl.json | 7 +-
.../settings/integrations-section.tsx | 373 ------------------
7 files changed, 24 insertions(+), 391 deletions(-)
diff --git a/messages/de.json b/messages/de.json
index bed6128bc..a1e1232e1 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -212,6 +212,7 @@
"themeLight": "Hell",
"collapseSidebar": "Einklappen",
"userMenu": "Benutzermenü",
+ "accountSettings": "Kontoeinstellungen",
"mood": "Stimmung",
"workouts": "Workouts",
"skipToContent": "Zum Inhalt springen",
@@ -3583,7 +3584,7 @@
"withingsCredentialsSaved": "Zugangsdaten gespeichert",
"withingsCredentialsSavedPlaceholder": "Gespeichert — neue eingeben zum Ersetzen",
"withingsCredentialsSavedPlaceholderSecret": "Gespeichert — neues eingeben zum Ersetzen",
- "withingsSaveCredentials": "Zugangsdaten speichern",
+ "withingsSaveCredentials": "Speichern",
"configured": "Konfiguriert",
"withingsSync": "Jetzt synchronisieren",
"withingsFullSync": "Alle Daten synchronisieren",
@@ -3608,7 +3609,7 @@
"whoopCredentialsSaved": "Zugangsdaten gespeichert",
"whoopCredentialsSavedPlaceholder": "Gespeichert — neu eingeben zum Ersetzen",
"whoopCredentialsSavedPlaceholderSecret": "Gespeichert — neu eingeben zum Ersetzen",
- "whoopSaveCredentials": "Zugangsdaten speichern",
+ "whoopSaveCredentials": "Speichern",
"whoopSync": "Jetzt synchronisieren",
"whoopFullSync": "Alle Daten synchronisieren",
"whoopFullSyncTitle": "Vollständige Synchronisierung?",
@@ -3635,7 +3636,7 @@
"fitbitCredentialsSaved": "Zugangsdaten gespeichert",
"fitbitCredentialsSavedPlaceholder": "Gespeichert — neu eingeben zum Ersetzen",
"fitbitCredentialsSavedPlaceholderSecret": "Gespeichert — neu eingeben zum Ersetzen",
- "fitbitSaveCredentials": "Zugangsdaten speichern",
+ "fitbitSaveCredentials": "Speichern",
"fitbitSync": "Jetzt synchronisieren",
"fitbitFullSync": "Alle Daten synchronisieren",
"fitbitFullSyncTitle": "Vollständige Synchronisierung?",
diff --git a/messages/en.json b/messages/en.json
index d9d9a08ba..862cc9cc6 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -212,6 +212,7 @@
"themeLight": "Light",
"collapseSidebar": "Collapse",
"userMenu": "User menu",
+ "accountSettings": "Account settings",
"mood": "Mood",
"workouts": "Workouts",
"skipToContent": "Skip to content",
@@ -3583,7 +3584,7 @@
"withingsCredentialsSaved": "Credentials saved",
"withingsCredentialsSavedPlaceholder": "Saved — enter new to replace",
"withingsCredentialsSavedPlaceholderSecret": "Saved — enter new to replace",
- "withingsSaveCredentials": "Save credentials",
+ "withingsSaveCredentials": "Save",
"configured": "Configured",
"withingsSync": "Sync now",
"withingsFullSync": "Sync all data",
@@ -3608,7 +3609,7 @@
"whoopCredentialsSaved": "Credentials saved",
"whoopCredentialsSavedPlaceholder": "Saved — enter new to replace",
"whoopCredentialsSavedPlaceholderSecret": "Saved — enter new to replace",
- "whoopSaveCredentials": "Save credentials",
+ "whoopSaveCredentials": "Save",
"whoopSync": "Sync now",
"whoopFullSync": "Sync all data",
"whoopFullSyncTitle": "Full synchronization?",
@@ -3635,7 +3636,7 @@
"fitbitCredentialsSaved": "Credentials saved",
"fitbitCredentialsSavedPlaceholder": "Saved — enter new to replace",
"fitbitCredentialsSavedPlaceholderSecret": "Saved — enter new to replace",
- "fitbitSaveCredentials": "Save credentials",
+ "fitbitSaveCredentials": "Save",
"fitbitSync": "Sync now",
"fitbitFullSync": "Sync all data",
"fitbitFullSyncTitle": "Full synchronization?",
diff --git a/messages/es.json b/messages/es.json
index 2629b9c90..72aef43b0 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -212,6 +212,7 @@
"themeLight": "Claro",
"collapseSidebar": "Contraer",
"userMenu": "Menú de usuario",
+ "accountSettings": "Ajustes de cuenta",
"mood": "Estado de ánimo",
"workouts": "Entrenamientos",
"skipToContent": "Saltar al contenido",
@@ -3583,7 +3584,7 @@
"withingsCredentialsSaved": "Credenciales guardadas",
"withingsCredentialsSavedPlaceholder": "Guardado — introduce uno nuevo para reemplazarlo",
"withingsCredentialsSavedPlaceholderSecret": "Guardado — introduce uno nuevo para reemplazarlo",
- "withingsSaveCredentials": "Guardar credenciales",
+ "withingsSaveCredentials": "Guardar",
"configured": "Configurado",
"withingsSync": "Sincronizar ahora",
"withingsFullSync": "Sincronizar todos los datos",
@@ -3608,7 +3609,7 @@
"whoopCredentialsSaved": "Credenciales guardadas",
"whoopCredentialsSavedPlaceholder": "Guardado — introduce nuevas para reemplazar",
"whoopCredentialsSavedPlaceholderSecret": "Guardado — introduce nuevas para reemplazar",
- "whoopSaveCredentials": "Guardar credenciales",
+ "whoopSaveCredentials": "Guardar",
"whoopSync": "Sincronizar ahora",
"whoopFullSync": "Sincronizar todos los datos",
"whoopFullSyncTitle": "¿Sincronización completa?",
@@ -3635,7 +3636,7 @@
"fitbitCredentialsSaved": "Credenciales guardadas",
"fitbitCredentialsSavedPlaceholder": "Guardado: introduce nuevas para reemplazar",
"fitbitCredentialsSavedPlaceholderSecret": "Guardado: introduce nuevas para reemplazar",
- "fitbitSaveCredentials": "Guardar credenciales",
+ "fitbitSaveCredentials": "Guardar",
"fitbitSync": "Sincronizar ahora",
"fitbitFullSync": "Sincronizar todos los datos",
"fitbitFullSyncTitle": "¿Sincronización completa?",
diff --git a/messages/fr.json b/messages/fr.json
index f352bd636..0f58f7f5f 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -212,6 +212,7 @@
"themeLight": "Clair",
"collapseSidebar": "Réduire",
"userMenu": "Menu utilisateur",
+ "accountSettings": "Paramètres du compte",
"mood": "Humeur",
"workouts": "Entraînements",
"skipToContent": "Aller au contenu",
@@ -3583,7 +3584,7 @@
"withingsCredentialsSaved": "Identifiants enregistrés",
"withingsCredentialsSavedPlaceholder": "Enregistré — saisissez-en un nouveau pour remplacer",
"withingsCredentialsSavedPlaceholderSecret": "Enregistré — saisissez-en un nouveau pour remplacer",
- "withingsSaveCredentials": "Enregistrer les identifiants",
+ "withingsSaveCredentials": "Enregistrer",
"configured": "Configuré",
"withingsSync": "Synchroniser maintenant",
"withingsFullSync": "Synchroniser toutes les données",
@@ -3608,7 +3609,7 @@
"whoopCredentialsSaved": "Identifiants enregistrés",
"whoopCredentialsSavedPlaceholder": "Enregistré — saisir de nouveaux pour remplacer",
"whoopCredentialsSavedPlaceholderSecret": "Enregistré — saisir de nouveaux pour remplacer",
- "whoopSaveCredentials": "Enregistrer les identifiants",
+ "whoopSaveCredentials": "Enregistrer",
"whoopSync": "Synchroniser",
"whoopFullSync": "Synchroniser toutes les données",
"whoopFullSyncTitle": "Synchronisation complète ?",
@@ -3635,7 +3636,7 @@
"fitbitCredentialsSaved": "Identifiants enregistrés",
"fitbitCredentialsSavedPlaceholder": "Enregistré — saisissez-en de nouveaux pour remplacer",
"fitbitCredentialsSavedPlaceholderSecret": "Enregistré — saisissez-en de nouveaux pour remplacer",
- "fitbitSaveCredentials": "Enregistrer les identifiants",
+ "fitbitSaveCredentials": "Enregistrer",
"fitbitSync": "Synchroniser maintenant",
"fitbitFullSync": "Tout synchroniser",
"fitbitFullSyncTitle": "Synchronisation complète ?",
diff --git a/messages/it.json b/messages/it.json
index a9325f736..e252fde9e 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -212,6 +212,7 @@
"themeLight": "Chiaro",
"collapseSidebar": "Riduci",
"userMenu": "Menu utente",
+ "accountSettings": "Impostazioni account",
"mood": "Umore",
"workouts": "Allenamenti",
"skipToContent": "Vai al contenuto",
@@ -3583,7 +3584,7 @@
"withingsCredentialsSaved": "Credenziali salvate",
"withingsCredentialsSavedPlaceholder": "Salvato — inseriscine uno nuovo per sostituirlo",
"withingsCredentialsSavedPlaceholderSecret": "Salvato — inseriscine uno nuovo per sostituirlo",
- "withingsSaveCredentials": "Salva credenziali",
+ "withingsSaveCredentials": "Salva",
"configured": "Configurato",
"withingsSync": "Sincronizza ora",
"withingsFullSync": "Sincronizza tutti i dati",
@@ -3608,7 +3609,7 @@
"whoopCredentialsSaved": "Credenziali salvate",
"whoopCredentialsSavedPlaceholder": "Salvato — inserisci nuove per sostituire",
"whoopCredentialsSavedPlaceholderSecret": "Salvato — inserisci nuove per sostituire",
- "whoopSaveCredentials": "Salva credenziali",
+ "whoopSaveCredentials": "Salva",
"whoopSync": "Sincronizza ora",
"whoopFullSync": "Sincronizza tutti i dati",
"whoopFullSyncTitle": "Sincronizzazione completa?",
@@ -3635,7 +3636,7 @@
"fitbitCredentialsSaved": "Credenziali salvate",
"fitbitCredentialsSavedPlaceholder": "Salvato — inserisci nuove per sostituire",
"fitbitCredentialsSavedPlaceholderSecret": "Salvato — inserisci nuove per sostituire",
- "fitbitSaveCredentials": "Salva credenziali",
+ "fitbitSaveCredentials": "Salva",
"fitbitSync": "Sincronizza ora",
"fitbitFullSync": "Sincronizza tutti i dati",
"fitbitFullSyncTitle": "Sincronizzazione completa?",
diff --git a/messages/pl.json b/messages/pl.json
index 1e6fe6c4a..645e40fd7 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -212,6 +212,7 @@
"themeLight": "Jasny",
"collapseSidebar": "Zwiń",
"userMenu": "Menu użytkownika",
+ "accountSettings": "Ustawienia konta",
"mood": "Nastrój",
"workouts": "Treningi",
"skipToContent": "Przejdź do treści",
@@ -3583,7 +3584,7 @@
"withingsCredentialsSaved": "Dane logowania zapisane",
"withingsCredentialsSavedPlaceholder": "Zapisano — wprowadź nowy, aby zastąpić",
"withingsCredentialsSavedPlaceholderSecret": "Zapisano — wprowadź nowy, aby zastąpić",
- "withingsSaveCredentials": "Zapisz dane logowania",
+ "withingsSaveCredentials": "Zapisz",
"configured": "Skonfigurowano",
"withingsSync": "Synchronizuj teraz",
"withingsFullSync": "Synchronizuj wszystkie dane",
@@ -3608,7 +3609,7 @@
"whoopCredentialsSaved": "Dane zapisane",
"whoopCredentialsSavedPlaceholder": "Zapisano — wpisz nowe, aby zastąpić",
"whoopCredentialsSavedPlaceholderSecret": "Zapisano — wpisz nowe, aby zastąpić",
- "whoopSaveCredentials": "Zapisz dane",
+ "whoopSaveCredentials": "Zapisz",
"whoopSync": "Synchronizuj teraz",
"whoopFullSync": "Synchronizuj wszystkie dane",
"whoopFullSyncTitle": "Pełna synchronizacja?",
@@ -3635,7 +3636,7 @@
"fitbitCredentialsSaved": "Dane uwierzytelniające zapisane",
"fitbitCredentialsSavedPlaceholder": "Zapisano — wpisz nowe, aby zastąpić",
"fitbitCredentialsSavedPlaceholderSecret": "Zapisano — wpisz nowe, aby zastąpić",
- "fitbitSaveCredentials": "Zapisz dane uwierzytelniające",
+ "fitbitSaveCredentials": "Zapisz",
"fitbitSync": "Synchronizuj teraz",
"fitbitFullSync": "Synchronizuj wszystkie dane",
"fitbitFullSyncTitle": "Pełna synchronizacja?",
diff --git a/src/components/settings/integrations-section.tsx b/src/components/settings/integrations-section.tsx
index 275033c30..afdc9d224 100644
--- a/src/components/settings/integrations-section.tsx
+++ b/src/components/settings/integrations-section.tsx
@@ -10,7 +10,6 @@ import {
Loader2,
RefreshCw,
Save,
- Smile,
Unlink,
} from "lucide-react";
@@ -42,14 +41,6 @@ import {
queryKeys,
} from "@/lib/query-keys";
-interface GlobalServiceAvailability {
- telegramGlobal: boolean;
- ntfyGlobal: boolean;
- webPushGlobal: boolean;
- apiGlobal: boolean;
- moodLogGlobal: boolean;
-}
-
// v1.4.15 Phase B2: shared status payload for both integration cards.
// v1.4.19 Phase A5: the redundant in-card status banner is gone — the
// IntegrationStatusPill now owns state + last-sync presentation, and
@@ -183,23 +174,11 @@ export function IntegrationsSection() {
const { t } = useTranslations();
const { isAuthenticated } = useAuth();
- const { data: globalServices } = useQuery({
- queryKey: queryKeys.settingsGlobalServices(),
- queryFn: async () => {
- const res = await fetch("/api/settings/global-services");
- if (!res.ok) throw new Error("Failed");
- return (await res.json()).data as GlobalServiceAvailability;
- },
- enabled: isAuthenticated,
- });
-
const { data: integrationStatus } = useIntegrationStatuses(isAuthenticated);
- const moodLogEnabled = globalServices?.moodLogGlobal ?? true;
const withingsViewModel = pickStatus(integrationStatus, "withings");
const whoopViewModel = pickStatus(integrationStatus, "whoop");
const fitbitViewModel = pickStatus(integrationStatus, "fitbit");
- const moodLogViewModel = pickStatus(integrationStatus, "moodlog");
return (
- {moodLogEnabled && }
);
}
@@ -1545,354 +1523,3 @@ function FitbitCard({
-
- {/* v1.4.33 — the webhook secret is a long hex string;
- on a 393 CSS px viewport the input + Copy button
- used to push past the card edge and trigger a body
- horizontal scroll. Stack vertically on `
-
-
-
-
- {t("settings.moodLogWebhookSecretHelp")}
-
-
- )}
-
- {/* v1.4.19 A5 — The "letzter Sync" line that used to
- live here is gone. The pill in the card header
- already carries that information; repeating it
- inside the body is the redundancy the maintainer flagged. The
- entry-count is the only number we still surface
- because it's an integration-specific datapoint the
- pill cannot convey. */}
-
-
- {/* v1.4.33 — the Sync / Voll-Sync / Trennen triplet
- overflowed the card edge on a 393 CSS px viewport
- (~376 px of buttons + gaps inside 345 px of inner
- width). Match the Withings card pattern: `flex-wrap`
- + `min-w-[10rem]` on `
-
-
-
-
-
-
-
-
-
- {t("settings.moodLogFullSyncTitle")}
-
-
- {t("settings.moodLogFullSyncDescription")}
-
-
-
- {t("common.cancel")}
- handleSync(true)}>
- {t("settings.moodLogFullSyncConfirm")}
-
-
-
-
-
-
-
- {/* v1.4.3: aligned with the Withings disconnect button —
- outline + text-destructive instead of solid destructive
- so the trigger reads as a reversible action; the actual
- "yes, disconnect" confirmation inside the dialog keeps
- its solid-red treatment. */}
-
-
-
-
-
- {t("settings.moodLogDisconnectTitle")}
-
-
- {t("settings.moodLogDisconnectDescription")}
-
-
-
- {t("common.cancel")}
-
- {t("settings.moodLogDisconnect")}
-
-
-
-
-
-
- )}
-
- {msg && (
-
- {msg}
-
- )}
-
-
- );
-}
From eda79794d9c417c32aec26cfffc9418b8c8ebe2f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 6 Jun 2026 23:14:50 +0200
Subject: [PATCH 03/11] feat(settings): trim the export surface and remove the
sharing page
Right-align the health-record export generate button. Remove the
duplicate doctor-report card from the export page (the doctor report is
reached through the health-record export now) and drop the now-unused
ArztberichtHeroCard. Remove the clinician share-link ("sharing") page,
its nav entry, slug, and section mapping; the backing model, routes, and
public clinician view stay in place for a later dedicated removal.
---
src/app/settings/[section]/page.tsx | 2 -
.../__tests__/arztbericht-hero-card.test.tsx | 121 -----
.../__tests__/export-section.test.tsx | 28 +-
.../__tests__/settings-shell.test.tsx | 3 -
.../__tests__/sharing-section.test.tsx | 125 -----
.../settings/arztbericht-hero-card.tsx | 178 ------
src/components/settings/export-section.tsx | 14 +-
.../settings/health-record-export-panel.tsx | 2 +-
.../settings/section-placeholder.tsx | 2 -
src/components/settings/section-slugs.ts | 4 -
src/components/settings/settings-shell.tsx | 6 -
src/components/settings/sharing-section.tsx | 505 ------------------
12 files changed, 15 insertions(+), 975 deletions(-)
delete mode 100644 src/components/settings/__tests__/arztbericht-hero-card.test.tsx
delete mode 100644 src/components/settings/__tests__/sharing-section.test.tsx
delete mode 100644 src/components/settings/arztbericht-hero-card.tsx
delete mode 100644 src/components/settings/sharing-section.tsx
diff --git a/src/app/settings/[section]/page.tsx b/src/app/settings/[section]/page.tsx
index 1210e2074..38e5fadae 100644
--- a/src/app/settings/[section]/page.tsx
+++ b/src/app/settings/[section]/page.tsx
@@ -11,7 +11,6 @@ import { ExportSection } from "@/components/settings/export-section";
import { IntegrationsSection } from "@/components/settings/integrations-section";
import { NotificationsSection } from "@/components/settings/notifications-section";
import { SectionPlaceholder } from "@/components/settings/section-placeholder";
-import { SharingSection } from "@/components/settings/sharing-section";
// v1.8.7.1 — `thresholds` (Targets) and `sources` (Sources) are two
// separate sections again. `ThresholdsSection` renders the target-range
// editor; `SourcesSection` (standalone mode) renders the source-priority
@@ -53,7 +52,6 @@ const SECTION_COMPONENTS: Record<
thresholds: ThresholdsSection,
sources: () => ,
api: ApiSection,
- sharing: SharingSection,
export: ExportSection,
advanced: AdvancedSection,
};
diff --git a/src/components/settings/__tests__/arztbericht-hero-card.test.tsx b/src/components/settings/__tests__/arztbericht-hero-card.test.tsx
deleted file mode 100644
index 06c1076cc..000000000
--- a/src/components/settings/__tests__/arztbericht-hero-card.test.tsx
+++ /dev/null
@@ -1,121 +0,0 @@
-/**
- * Arztbericht (doctor-report) card contract.
- *
- * v1.4.37 W7a promoted this surface to the page hero; v1.12 demotes it
- * back to a small secondary card at the bottom of `/settings/export`
- * (the health-record export now owns the hero). The card stays fully
- * functional — only the framing and visual weight changed. This suite
- * pins the SSR contract:
- *
- * 1. The card renders with the expected `data-testid` slot.
- * 2. The value statement is present (Marc-Voice copy, no AI mention).
- * 3. The CTA button is present, clears the 44 px touch floor, and
- * carries an `aria-describedby` link to the value statement.
- * 4. The CTA is wired to a click handler (the prop `data-testid`
- * surface is the e2e contract).
- * 5. Both DE and EN locales resolve their copy without leaking the
- * raw i18n key.
- *
- * Project convention is SSR-only tests (no `@testing-library/react`).
- * The actual click-flow + PDF generation is exercised by the e2e
- * Playwright path and the dialog's own integration tests.
- */
-
-import { describe, expect, it, vi } from "vitest";
-import { renderToStaticMarkup } from "react-dom/server";
-
-vi.mock("next/navigation", () => ({
- useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
- usePathname: () => "/settings/export",
-}));
-
-import { I18nProvider } from "@/lib/i18n/context";
-import { ArztberichtHeroCard } from "../arztbericht-hero-card";
-
-function renderSSR(node: React.ReactElement, locale: "en" | "de" = "en") {
- return renderToStaticMarkup(
- {node},
- );
-}
-
-describe(" — SSR contract", () => {
- it("renders the card slot with eyebrow + title", () => {
- const html = renderSSR();
- expect(html).toContain('data-testid="export-hero-doctor-report"');
- // Title falls back to the existing doctorReport.title key — keeps
- // a single source-of-truth for the brand name.
- expect(html).toContain("Doctor Report");
- // Eyebrow surfaces the use-case framing.
- expect(html).toContain("Doctor visit");
- });
-
- it("renders the value statement as a Marc-Voice one-liner", () => {
- const html = renderSSR();
- expect(html).toContain('data-testid="export-hero-doctor-report-value"');
- expect(html).toContain("printable PDF report");
- // No AI/Coach name-drop in the user-facing artefact (Marc-Voice).
- expect(html.toLowerCase()).not.toContain("coach");
- expect(html.toLowerCase()).not.toContain(" ai ");
- });
-
- it("renders the CTA with min-h-11 touch target + aria-describedby", () => {
- const html = renderSSR();
- expect(html).toContain('data-testid="export-hero-doctor-report-action"');
- // 44 px touch floor (WCAG 2.5.5) — the demoted CTA keeps `min-h-11`
- // on mobile and falls back to the compact `sm:min-h-9` on pointer
- // devices.
- expect(html).toContain("min-h-11");
- // Aria-describedby links the CTA to the value statement so screen
- // readers announce the framing alongside the action label.
- expect(html).toMatch(/aria-describedby="[^"]+"/);
- });
-
- it("does NOT leak raw i18n keys", () => {
- const html = renderSSR();
- expect(html).not.toContain("settings.sections.export.hero");
- expect(html).not.toContain("settings.sections.export.cards.doctorReport");
- });
-
- it("renders German copy when the DE locale is active", () => {
- const html = renderSSR(, "de");
- expect(html).toContain("Arztbericht");
- expect(html).toContain("Arzttermin");
- // Umlauts encoded as their literal UTF-8 character (not their HTML
- // entity), matching the rest of the codebase.
- expect(html).toContain("druckbarer PDF-Bericht");
- expect(html).not.toContain("settings.sections.export.hero");
- });
-
- it("renders as a plain secondary card, not a hero", () => {
- const html = renderSSR();
- // v1.12 demoted the doctor-report from the page hero — the
- // health-record export now owns the hero treatment, so this card
- // must NOT carry the hero gradient/glow utilities.
- expect(html).not.toContain("hero-gradient");
- expect(html).not.toContain("glow-purple");
- // It renders with the standard secondary-card surface.
- expect(html).toContain("bg-card");
- });
-
- it("does not paint dialog markup in the closed-state SSR pass", () => {
- // Radix Dialog renders its portal content lazily, so the closed
- // SSR pass never carries `role="dialog"`. The CTA testid IS
- // present so the e2e suite can drive the open from there. Pinning
- // this contract guards against an accidental `defaultOpen={true}`
- // regression that would briefly paint the dialog above the page on
- // every cold mount.
- const html = renderSSR();
- expect(html).toContain('data-testid="export-hero-doctor-report-action"');
- expect(html).not.toContain('role="dialog"');
- });
-});
-
-describe(" — heading hierarchy", () => {
- it("uses an
so it nests cleanly below the page-level
", () => {
- const html = renderSSR();
- // Page-level h1 (settings-section-export-title) wraps the section;
- // hero owns the h2 slot. The export-section test pins the outer
- // h1; this assertion pins the hero's contribution.
- expect(html).toMatch(/
]*id="export-hero-doctor-report-title"/);
- });
-});
diff --git a/src/components/settings/__tests__/export-section.test.tsx b/src/components/settings/__tests__/export-section.test.tsx
index 1cfdfd331..7f6496218 100644
--- a/src/components/settings/__tests__/export-section.test.tsx
+++ b/src/components/settings/__tests__/export-section.test.tsx
@@ -7,18 +7,16 @@
* - Medications CSV (with optional intake-history toggle)
* - Mood CSV
* - Full JSON Backup
- * - Doctor Report (PDF) — small secondary card at the bottom (v1.12)
*
* Each card must surface a title, a 1-line description, and a
* download/generate button. SSR-only smoke test — interaction is
* exercised by the e2e suite.
*
- * v1.12 — the health-record export takes the page hero; the
- * doctor-report card (``) is demoted to a small
- * secondary card at the bottom of the page. The four CSV/JSON tiles
- * stay under a "Weitere Export-Optionen" / "Other export options"
- * sub-heading. Each component owns its own contract test; this suite
- * pins the page-level shape.
+ * The health-record export takes the page hero. The doctor-report PDF
+ * now lives under the health-record export and is no longer offered as
+ * a separate card here. The four CSV/JSON tiles stay under a "Weitere
+ * Export-Optionen" / "Other export options" sub-heading. Each component
+ * owns its own contract test; this suite pins the page-level shape.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
@@ -68,12 +66,11 @@ describe(" — SSR smoke", () => {
expect(html).toContain("glow-purple");
});
- it("mounts the demoted doctor-report card", () => {
+ it("no longer mounts a separate doctor-report card", () => {
const html = render();
- // The card owns its own contract suite; here we pin that the page
- // mounts it, and that the legacy in-grid doctor-report card is
- // gone. The card no longer carries the hero gradient/glow (v1.12).
- expect(html).toContain('data-testid="export-hero-doctor-report"');
+ // The doctor-report PDF now lives under the health-record export;
+ // the Export page no longer offers it as a separate surface.
+ expect(html).not.toContain('data-testid="export-hero-doctor-report"');
expect(html).not.toContain('data-testid="export-card-doctor-report"');
});
@@ -107,19 +104,16 @@ describe(" — SSR smoke", () => {
expect(html).toContain('data-testid="export-medications-include-intake"');
});
- it("renders a download/generate button per secondary card + the hero CTA", () => {
+ it("renders a download/generate button per secondary card", () => {
const html = render();
- // Four secondary cards each carry an `export-action-*` testid; the
- // hero owns the `export-hero-doctor-report-action` slot.
+ // Four secondary cards each carry an `export-action-*` testid.
const buttonMatches = html.match(/data-testid="export-action-/g);
expect(buttonMatches?.length ?? 0).toBe(4);
- expect(html).toContain('data-testid="export-hero-doctor-report-action"');
});
it("German locale renders the DE heading + hero copy", () => {
const html = render(, "de");
expect(html).toContain("Export");
- expect(html).toContain("Arztbericht");
expect(html).toContain("Weitere Export-Optionen");
expect(html).not.toContain("settings.sections.export.");
});
diff --git a/src/components/settings/__tests__/settings-shell.test.tsx b/src/components/settings/__tests__/settings-shell.test.tsx
index 5584e3330..25e3fcb6b 100644
--- a/src/components/settings/__tests__/settings-shell.test.tsx
+++ b/src/components/settings/__tests__/settings-shell.test.tsx
@@ -48,8 +48,6 @@ describe("SETTINGS_SECTION_SLUGS", () => {
// IW-D merged it into `thresholds`.
// v1.8.7.1 — `sources` (Sources) is its own slug again, sitting
// between `thresholds` (Targets) and `ai`.
- // v1.11.0 — `sharing` (clinician share links) added between `api` and
- // `export`. Section count: 12.
expect([...SETTINGS_SECTION_SLUGS]).toEqual([
"account",
"integrations",
@@ -59,7 +57,6 @@ describe("SETTINGS_SECTION_SLUGS", () => {
"sources",
"ai",
"api",
- "sharing",
"export",
"advanced",
"about",
diff --git a/src/components/settings/__tests__/sharing-section.test.tsx b/src/components/settings/__tests__/sharing-section.test.tsx
deleted file mode 100644
index f09fa06e4..000000000
--- a/src/components/settings/__tests__/sharing-section.test.tsx
+++ /dev/null
@@ -1,125 +0,0 @@
-/**
- * v1.11.0 — Settings → Sharing (Epic C, C7).
- *
- * The owner share-link surface. The security-load-bearing guarantees the
- * test pins:
- * 1. The list never carries a raw `hls_` token — the server stores only the
- * hash, so the list response shape has no token field and the rendered
- * markup must not surface one.
- * 2. The one-time token reveal card (`share-token-reveal`) is absent until a
- * create succeeds — on first paint (no token in state) it must not render.
- * 3. Active links render with a revoke control; revoked/expired links fold
- * into the inactive list.
- * 4. The expiry input is bounded at the server cap (90 days).
- *
- * SSR-static render only (no jsdom), matching the sibling settings-section
- * tests. The TanStack hooks are mocked so the query returns a fixed link list.
- */
-import { describe, expect, it, vi } from "vitest";
-import { renderToStaticMarkup } from "react-dom/server";
-
-vi.mock("@/hooks/use-auth", () => ({
- useAuth: () => ({
- user: { id: "u1", role: "USER" },
- isAuthenticated: true,
- isLoading: false,
- refetch: vi.fn(),
- }),
-}));
-
-const FIXTURE_LINKS = [
- {
- id: "link-active",
- label: "Cardiology referral",
- rangeStart: "2026-01-01T00:00:00.000Z",
- rangeEnd: null,
- resourceTypes: ["Patient", "Observation"],
- allowFhirApi: true,
- expiresAt: "2099-01-01T00:00:00.000Z",
- createdAt: "2026-06-01T00:00:00.000Z",
- revokedAt: null,
- lastAccessAt: "2026-06-02T10:00:00.000Z",
- accessCount: 3,
- active: true,
- },
- {
- id: "link-revoked",
- label: "Old GP link",
- rangeStart: "2026-01-01T00:00:00.000Z",
- rangeEnd: null,
- resourceTypes: [],
- allowFhirApi: false,
- expiresAt: "2099-01-01T00:00:00.000Z",
- createdAt: "2026-05-01T00:00:00.000Z",
- revokedAt: "2026-05-15T00:00:00.000Z",
- lastAccessAt: null,
- accessCount: 0,
- active: false,
- },
-];
-
-vi.mock("@tanstack/react-query", () => ({
- useQuery: () => ({ data: FIXTURE_LINKS, isLoading: false }),
- useMutation: () => ({ mutate: vi.fn(), isPending: false }),
- useQueryClient: () => ({ invalidateQueries: vi.fn() }),
-}));
-
-import { I18nProvider } from "@/lib/i18n/context";
-import { SharingSection } from "../sharing-section";
-
-function render(locale: "en" | "de" = "en") {
- return renderToStaticMarkup(
-
-
- ,
- );
-}
-
-describe(" — owner share-link surface (C7)", () => {
- it("renders the create form with a 90-day-capped expiry input", () => {
- const html = render();
- expect(html).toContain("New share link");
- // The expiry field carries the server's max-days cap.
- expect(html).toContain('max="90"');
- expect(html).toContain("Create link");
- });
-
- it("does not reveal a token before a create succeeds", () => {
- const html = render();
- // The one-time reveal card only mounts once `newToken` state is set.
- expect(html).not.toContain('data-testid="share-token-reveal"');
- });
-
- it("lists the active link with a revoke control and never a raw token", () => {
- const html = render();
- expect(html).toContain('data-testid="share-active-list"');
- expect(html).toContain("Cardiology referral");
- expect(html).toContain("Revoke");
- // The list response shape carries no token; the markup must not surface
- // an `hls_` string anywhere.
- expect(html).not.toContain("hls_");
- // The FHIR badge surfaces for the FHIR-enabled link.
- expect(html).toContain("FHIR");
- });
-
- it("folds revoked links into the inactive list (not the active list)", () => {
- const html = render();
- // The active list must NOT contain the revoked link's label.
- const activeListStart = html.indexOf('data-testid="share-active-list"');
- const activeListEnd = html.indexOf('data-testid="share-inactive-list"');
- // Inactive list is collapsed by default, so the label only appears once
- // the disclosure is open — but the active list must never carry it.
- const activeSlice =
- activeListEnd > activeListStart
- ? html.slice(activeListStart, activeListEnd)
- : html.slice(activeListStart);
- expect(activeSlice).not.toContain("Old GP link");
- // The inactive-count disclosure surfaces.
- expect(html).toContain("Revoked and expired");
- });
-
- it("renders localized section copy", () => {
- const html = render("de");
- expect(html).toContain("Neuer Freigabe-Link");
- });
-});
diff --git a/src/components/settings/arztbericht-hero-card.tsx b/src/components/settings/arztbericht-hero-card.tsx
deleted file mode 100644
index 7783457da..000000000
--- a/src/components/settings/arztbericht-hero-card.tsx
+++ /dev/null
@@ -1,178 +0,0 @@
-"use client";
-
-/**
- * Settings → Export Arztbericht (doctor-report) card.
- *
- * The doctor-report PDF is the artefact a user carries into a doctor's
- * appointment. v1.4.37 W7a promoted it to the page hero; v1.12 demotes
- * it back to a small secondary card at the bottom of the export page —
- * the health-record export now owns the page hero. The card is fully
- * functional: the CTA routes through the existing ``
- * + `/api/doctor-report` flow exactly as before. Only the framing and
- * visual weight changed.
- *
- * Layout (compact card, no hero gradient):
- *
- * ┌─ card ────────────────────────────────────────────┐
- * │ [icon] H2 title eyebrow │
- * │ one-line value statement │
- * │ [Configure & generate] PDF · printable │
- * └───────────────────────────────────────────────────┘
- *
- * a11y:
- * - The CTA button clears the 44 px touch floor (`min-h-11`).
- * - `focus-visible:ring` ships via the default `
)}
-
+
= {
sources: Layers,
ai: Sparkles,
api: KeyRound,
- sharing: Share2,
export: Download,
advanced: Settings2,
about: Info,
diff --git a/src/components/settings/section-slugs.ts b/src/components/settings/section-slugs.ts
index 2b83ff968..76f010ef5 100644
--- a/src/components/settings/section-slugs.ts
+++ b/src/components/settings/section-slugs.ts
@@ -22,10 +22,6 @@ export const SETTINGS_SECTION_SLUGS = [
"sources",
"ai",
"api",
- // v1.11.0 — `sharing` owns clinician share links: a time-boxed, scope-
- // frozen read-only view of the owner's record, optionally a scoped FHIR
- // face. Sits next to `api` (the other "hand a credential out" surface).
- "sharing",
"export",
"advanced",
"about",
diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx
index 954475e1f..22043e34a 100644
--- a/src/components/settings/settings-shell.tsx
+++ b/src/components/settings/settings-shell.tsx
@@ -25,7 +25,6 @@ import {
LayoutDashboard,
Link2,
Settings2,
- Share2,
SlidersHorizontal,
Sparkles,
User,
@@ -110,11 +109,6 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
},
{ slug: "ai", titleKey: "settings.sections.ai.title", icon: Sparkles },
{ slug: "api", titleKey: "settings.sections.api.title", icon: KeyRound },
- {
- slug: "sharing",
- titleKey: "settings.sections.sharing.title",
- icon: Share2,
- },
{
slug: "export",
titleKey: "settings.sections.export.title",
diff --git a/src/components/settings/sharing-section.tsx b/src/components/settings/sharing-section.tsx
deleted file mode 100644
index 55096772d..000000000
--- a/src/components/settings/sharing-section.tsx
+++ /dev/null
@@ -1,505 +0,0 @@
-"use client";
-
-/**
- * v1.11.0 — Settings → Sharing (Epic C, C7).
- *
- * The OWNER surface for clinician share links. A share link is a time-boxed,
- * scope-frozen, read-only view of the owner's own health record at
- * `/c/`, optionally exposing a scoped read-only FHIR face. The raw
- * `hls_` token is shown EXACTLY ONCE on create — the list never carries it
- * (the server only stores its hash), so the copy-on-create card is the single
- * chance to capture it.
- *
- * Client island: the create form, the active/revoked lists, and the revoke
- * action all need state and mutate via the C4 lifecycle API
- * (`/api/share-links`). Reads unwrap `(await res.json()).data`; the query key
- * comes from the centralised factory. No markdown anywhere — every value
- * renders as escaped React text.
- */
-import { useMemo, useState } from "react";
-import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { Copy, Loader2, Share2, Trash2 } from "lucide-react";
-
-import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
- AlertDialogTrigger,
-} from "@/components/ui/alert-dialog";
-import { Badge } from "@/components/ui/badge";
-import { Button } from "@/components/ui/button";
-import { Checkbox } from "@/components/ui/checkbox";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-import { Switch } from "@/components/ui/switch";
-import { SettingsCardHeader } from "@/components/settings/_card-header";
-import { useAuth } from "@/hooks/use-auth";
-import { formatDate, formatDateTime } from "@/lib/format";
-import { useTranslations } from "@/lib/i18n/context";
-import { queryKeys } from "@/lib/query-keys";
-
-/** The FHIR resource types a share link may serve — mirrors C4's enum. */
-const RESOURCE_TYPES = [
- "Patient",
- "Observation",
- "MedicationStatement",
- "MedicationAdministration",
-] as const;
-type ResourceType = (typeof RESOURCE_TYPES)[number];
-
-/** Maximum lifetime, in days — mirrors `SHARE_LINK_MAX_DAYS` on the server. */
-const MAX_DAYS = 90;
-const DEFAULT_DAYS = 30;
-
-/** Owner-facing shape returned by `GET /api/share-links` (never the token). */
-interface ShareLinkSummary {
- id: string;
- label: string;
- rangeStart: string;
- rangeEnd: string | null;
- resourceTypes: string[];
- allowFhirApi: boolean;
- expiresAt: string;
- createdAt: string;
- revokedAt: string | null;
- lastAccessAt: string | null;
- accessCount: number;
- active: boolean;
-}
-
-export function SharingSection() {
- const { t } = useTranslations();
-
- return (
-
-
-
- );
-}
From 1b283a9c01dfbb2ea52854077f6c56e7b16fca46 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 6 Jun 2026 23:15:05 +0200
Subject: [PATCH 04/11] feat(settings): land the account page from the avatar,
tidy the token row
Clicking the avatar (and the name beside it) in the sidebar user menu
now routes straight to the account settings. On the per-medication API
token row, drop the separate "Copy URL" button in favour of an inline
read-only endpoint field with a copy icon, and switch the generate-token
button to the standard outline variant so it no longer renders as the
lone primary-coloured control on the row.
---
src/components/layout/sidebar-nav.tsx | 78 ++++++++++++-------
.../medications/sections/api-tokens-row.tsx | 41 ++++++----
2 files changed, 72 insertions(+), 47 deletions(-)
diff --git a/src/components/layout/sidebar-nav.tsx b/src/components/layout/sidebar-nav.tsx
index 4e62bf571..ba4cf3670 100644
--- a/src/components/layout/sidebar-nav.tsx
+++ b/src/components/layout/sidebar-nav.tsx
@@ -124,22 +124,31 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
className="w-60"
sideOffset={8}
>
-
+ {/* R20 — the user header is the direct route into the account
+ settings. Clicking the avatar / name jumps straight to
+ `/settings/account` rather than making the user hunt for a
+ separate "Settings" entry. */}
+
+
+
+ {avatarUrl && }
+
+ {getInitials(user.username)}
+
+
+
+ {/* R20 — the avatar + name in the expanded footer routes straight
+ into the account settings; the kebab to the right still opens
+ the rest of the user menu. */}
+
+
+ {avatarUrl && }
+
+ {getInitials(user.username)}
+
+
+
+
{user.username}
+ {user.email && (
+
+ {user.email}
+
+ )}
+
+
{t("medications.detail.api.caption", { name: medicationName })}
- {/* The raw endpoint URL is redundant with the copy button below, so
- it is rendered muted + single-line-truncated rather than as a
- full-width block — the long absolute URL was pure visual noise. */}
- ` carries the value; the icon button copies it. */}
+
void copy(endpoint, t("medications.detail.api.urlCopied"))}
- className="min-h-11 sm:min-h-9"
- data-slot="api-tokens-copy-url"
- >
-
- {t("medications.detail.api.copyUrl")}
-
- void mintToken()}
disabled={busy}
From 6d5145bdd9c3442285f60730208b81968ee786a2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 6 Jun 2026 23:15:09 +0200
Subject: [PATCH 05/11] test(settings): update integrations contract for the
dropped moodLog card
The single-status-display contract now expects three integration cards
(Withings, WHOOP, Google Health) instead of four after the moodLog card
removal.
---
.../__tests__/integrations-section.test.tsx | 37 +++++--------------
1 file changed, 9 insertions(+), 28 deletions(-)
diff --git a/src/components/settings/__tests__/integrations-section.test.tsx b/src/components/settings/__tests__/integrations-section.test.tsx
index b1f7511e9..e6e560f78 100644
--- a/src/components/settings/__tests__/integrations-section.test.tsx
+++ b/src/components/settings/__tests__/integrations-section.test.tsx
@@ -131,9 +131,9 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
});
const html = render();
- // Exactly one pill per card → 4 pills total (Withings, moodLog, WHOOP,
- // Fitbit / Google Health).
- expect(count(html, 'data-testid="integration-status-pill"')).toBe(4);
+ // Exactly one pill per card → 3 pills total (Withings, WHOOP,
+ // Fitbit / Google Health). The moodLog integration was removed.
+ expect(count(html, 'data-testid="integration-status-pill"')).toBe(3);
// The redundant banner from v1.4.15 is gone.
expect(html).not.toContain('data-testid="integration-status-banner"');
// Card-body "letzter Sync" repetition is gone — no
@@ -216,19 +216,12 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
expect(html).not.toContain('data-testid="integration-status-banner"');
});
- it("Mood Log disconnected state shows 'Not connected' on the pill, no extra status surfaces", () => {
+ it("disconnected state shows 'Not connected' on the pill, no extra status surfaces", () => {
setIntegrationStatus({
threshold: 3,
integrations: [
{
integration: "withings",
- state: "connected",
- lastSuccessAt: null,
- lastAttemptAt: null,
- lastError: null,
- },
- {
- integration: "moodlog",
state: "disconnected",
lastSuccessAt: "2026-05-08T10:00:00.000Z",
lastAttemptAt: "2026-05-08T10:00:00.000Z",
@@ -240,7 +233,7 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
const html = render();
expect(html).toContain("Not connected");
expect(html).not.toContain('data-testid="integration-status-banner"');
- // Mood Log card no longer has a trailing "letzter Sync" line.
+ // No trailing "letzter Sync" line outside the pill.
expect(html).not.toContain("Last sync:");
});
@@ -285,7 +278,7 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
expect(html).toContain("Withings activity error: 293");
});
- it("Mood Log card carries the visual divider Withings has (consistency)", () => {
+ it("every integration card carries the visual divider (consistency)", () => {
setIntegrationStatus({
threshold: 3,
integrations: [
@@ -300,25 +293,13 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
legacyLastSyncedAt: "2026-05-09T18:00:00.000Z",
hasActivityScope: true,
},
- {
- integration: "moodlog",
- state: "connected",
- lastSuccessAt: "2026-05-09T17:00:00.000Z",
- lastAttemptAt: "2026-05-09T17:00:00.000Z",
- lastError: null,
- configured: true,
- enabled: true,
- legacyLastSyncedAt: "2026-05-09T17:00:00.000Z",
- entryCount: 42,
- webhookSecret: "secret123",
- },
],
});
const html = render();
// Every integration card includes the section divider data-testid so the
- // header → body separation is visually consistent (Withings, moodLog,
- // WHOOP, Fitbit / Google Health).
- expect(count(html, 'data-testid="integration-card-divider"')).toBe(4);
+ // header → body separation is visually consistent (Withings, WHOOP,
+ // Fitbit / Google Health). The moodLog integration was removed.
+ expect(count(html, 'data-testid="integration-card-divider"')).toBe(3);
});
});
From 19af59ebfeb4ab2ce4149114ba3f7e2599018419 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 6 Jun 2026 23:25:41 +0200
Subject: [PATCH 06/11] fix(insights): title the period-in-review card and hide
the day-signal card when there is no signal
Make "Period in review" the period-narrative card heading (a proper
title, not a faint uppercase label) and drop the possessive across all
locales.
Render the today-signal card only when there is an actual signal: an
all-clear day (every vital inside its personal range) or an account still
building its baselines now produces no card, rather than an empty header.
---
messages/de.json | 2 +-
messages/en.json | 2 +-
messages/es.json | 2 +-
messages/fr.json | 2 +-
messages/it.json | 2 +-
messages/pl.json | 2 +-
.../coincident-deviation-card.test.tsx | 20 ++---
.../insights/coincident-deviation-card.tsx | 76 +++++--------------
.../insights/period-narrative-card.tsx | 4 +-
9 files changed, 34 insertions(+), 78 deletions(-)
diff --git a/messages/de.json b/messages/de.json
index a1e1232e1..3635e3cc5 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -1642,7 +1642,7 @@
"regenerateAnalysis": "Einschätzung neu erstellen",
"regenerateSuccess": "Einschätzung wurde neu erstellt",
"warmStarted": "Einschätzungen werden im Hintergrund erstellt",
- "narrativeTitle": "Dein Zeitraum im Rückblick",
+ "narrativeTitle": "Zeitraum im Rückblick",
"narrativeWeek": "Diese Woche",
"narrativeMonth": "Dieser Monat",
"narrativePreparing": "Deine Zusammenfassung wird erstellt…",
diff --git a/messages/en.json b/messages/en.json
index 862cc9cc6..5685bbc0f 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -1642,7 +1642,7 @@
"regenerateAnalysis": "Re-run assessment",
"regenerateSuccess": "Assessment refreshed",
"warmStarted": "Assessments are being prepared in the background",
- "narrativeTitle": "Your period in review",
+ "narrativeTitle": "Period in review",
"narrativeWeek": "This week",
"narrativeMonth": "This month",
"narrativePreparing": "Preparing your summary…",
diff --git a/messages/es.json b/messages/es.json
index 72aef43b0..2621b9d2e 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -1642,7 +1642,7 @@
"regenerateAnalysis": "Volver a generar la valoración",
"regenerateSuccess": "Valoración regenerada",
"warmStarted": "Las valoraciones se están preparando en segundo plano",
- "narrativeTitle": "Tu período en resumen",
+ "narrativeTitle": "Período en resumen",
"narrativeWeek": "Esta semana",
"narrativeMonth": "Este mes",
"narrativePreparing": "Preparando tu resumen…",
diff --git a/messages/fr.json b/messages/fr.json
index 0f58f7f5f..a77dc2824 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -1642,7 +1642,7 @@
"regenerateAnalysis": "Relancer l’évaluation",
"regenerateSuccess": "Évaluation regénérée",
"warmStarted": "Les évaluations sont en cours de préparation en arrière-plan",
- "narrativeTitle": "Votre période en revue",
+ "narrativeTitle": "Période en revue",
"narrativeWeek": "Cette semaine",
"narrativeMonth": "Ce mois-ci",
"narrativePreparing": "Préparation de votre résumé…",
diff --git a/messages/it.json b/messages/it.json
index e252fde9e..f4c0652b1 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -1642,7 +1642,7 @@
"regenerateAnalysis": "Rigenera la valutazione",
"regenerateSuccess": "Valutazione rigenerata",
"warmStarted": "Le valutazioni vengono preparate in background",
- "narrativeTitle": "Il tuo periodo in sintesi",
+ "narrativeTitle": "Periodo in sintesi",
"narrativeWeek": "Questa settimana",
"narrativeMonth": "Questo mese",
"narrativePreparing": "Preparazione del riepilogo…",
diff --git a/messages/pl.json b/messages/pl.json
index 645e40fd7..474e3ccac 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -1642,7 +1642,7 @@
"regenerateAnalysis": "Uruchom ocenę ponownie",
"regenerateSuccess": "Ocena wygenerowana ponownie",
"warmStarted": "Oceny są przygotowywane w tle",
- "narrativeTitle": "Twój okres w skrócie",
+ "narrativeTitle": "Okres w skrócie",
"narrativeWeek": "Ten tydzień",
"narrativeMonth": "Ten miesiąc",
"narrativePreparing": "Przygotowywanie podsumowania…",
diff --git a/src/components/insights/__tests__/coincident-deviation-card.test.tsx b/src/components/insights/__tests__/coincident-deviation-card.test.tsx
index d4fb07e8e..f964d32ad 100644
--- a/src/components/insights/__tests__/coincident-deviation-card.test.tsx
+++ b/src/components/insights/__tests__/coincident-deviation-card.test.tsx
@@ -115,18 +115,14 @@ describe("", () => {
expect(fired).toContain("min-h-48");
});
- it("renders the insufficient (building baselines) state, never an alarm", () => {
+ it("renders nothing in the insufficient (building baselines) state", () => {
mock(insufficient());
const html = render();
- expect(html).toContain('data-state="insufficient"');
- expect(html).toContain('data-slot="coincident-building"');
- expect(html).toContain('data-slot="coverage-meter"');
- // Calm, not amber/red.
- expect(html).not.toContain("border-warning");
- expect(html).not.toContain("text-destructive");
+ // No signal yet → the section stays absent rather than an empty husk.
+ expect(html).toBe("");
});
- it("renders the all-clear state with the count checked", () => {
+ it("renders nothing in the all-clear state (no signal to show)", () => {
mock(
ok({
fired: false,
@@ -136,12 +132,8 @@ describe("", () => {
}),
);
const html = render();
- expect(html).toContain('data-state="all-clear"');
- expect(html).toContain("All your vitals are within their personal range");
- expect(html).toContain("text-success");
- // No alarm tone.
- expect(html).not.toContain("border-warning");
- expect(html).not.toContain("text-destructive");
+ // Every vital inside its personal range → hide the whole card.
+ expect(html).toBe("");
});
it("renders the watch state for a single out-of-band vital, not the fired tone", () => {
diff --git a/src/components/insights/coincident-deviation-card.tsx b/src/components/insights/coincident-deviation-card.tsx
index cd427fffa..e7b4ff005 100644
--- a/src/components/insights/coincident-deviation-card.tsx
+++ b/src/components/insights/coincident-deviation-card.tsx
@@ -1,6 +1,6 @@
"use client";
-import { CheckCircle2, AlertTriangle, Activity } from "lucide-react";
+import { AlertTriangle, Activity } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
@@ -17,22 +17,20 @@ import type { DerivedProvenance } from "@/lib/insights/derived/types";
/**
* v1.10.3 — "Today's signal" headline card.
*
- * Promotes the COINCIDENT_DEVIATION flag from one buried below-the-fold vitals
- * tile (painted only when it fired) to a dedicated, always-mounted card at the
- * top of the Insights overview, matching the always-present pattern Apple
- * Vitals / WHOOP Health Monitor / Oura Symptom Radar use. The signal is the
- * daily headline read, not an alarm: an "all clear" day is itself the
- * reassuring product.
+ * Surfaces the COINCIDENT_DEVIATION flag at the top of the Insights overview as
+ * a calm awareness read drawn off the SINGLE existing `Derived` payload — no new
+ * engine math, no new route, no schema change.
*
- * It renders four calm states off the SINGLE existing `Derived` payload — no
- * new engine math, no new route, no schema change:
- * - insufficient (< 2 banded vitals) → "building your baselines" + coverage.
- * - all-clear (ok, !fired, 0 contributing) → a green check + the count
- * checked.
- * - watch (ok, !fired, 1 contributing) → a calm, non-alert line naming the
- * one vital.
- * - fired (ok, fired, ≥ 2 contributing) → an amber awareness card naming the
- * vitals + the load-bearing "possible factors — never a cause" line.
+ * The card renders ONLY when there is an actual signal to show. A day where
+ * every vital sits inside its personal range (all-clear), or an account still
+ * building its baselines (insufficient), produces no card at all — the section
+ * stays absent rather than surfacing an empty header. The two states that DO
+ * render:
+ * - watch (ok, 1 contributing vital, or a thin-history multi-signal flag) →
+ * a calm, non-alert line naming the vital.
+ * - fired (ok, fired, ≥ 2 contributing, with enough history) → an amber
+ * awareness card naming the vitals + the load-bearing "possible factors —
+ * never a cause" line.
*
* Restraint guarantees: at most the `warning` (amber) band — never
* `destructive`/red, never a score, never a chart (so no Recharts), never a
@@ -167,22 +165,10 @@ export function CoincidentDeviationCard({
);
}
- // Building the baselines — fewer than two banded vitals. Calm, never an
- // alarm, never blank.
+ // Building the baselines — fewer than two banded vitals. There is no signal
+ // to show, so the section stays absent rather than rendering an empty husk.
if (data.status === "insufficient") {
- return (
-
-
-
- {t("insights.derived.coincident.building")}
-
-
-
-
- );
+ return null;
}
const v = data.value!;
@@ -208,32 +194,10 @@ export function CoincidentDeviationCard({
? "fired"
: "watch";
+ // All clear — every vital is inside its personal range, so there is no signal
+ // worth a card. Hide the whole section rather than surface an empty header.
if (state === "all-clear") {
- return (
-
+
{narrative?.provenance ? (
) : null}
From 0e184e7f1bc1169aeca395fbf0758820b1acf972 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 6 Jun 2026 23:25:47 +0200
Subject: [PATCH 07/11] fix(medications): drop the duplicate next-dose line and
the injection-site recommendation from the card
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The open-cycle status line repeated the upcoming-dose timing already
shown in the card's next-intake slot; the calm on-track "next dose in N
days" phrasing is now suppressed, leaving only the actionable due /
overdue tiers.
Remove the "recommended next site" nudge from the GLP-1 card — it is
noise on the at-a-glance surface. Injection-site tracking stays: the last
site still reads on the last-injection line and the post-dose picker
still captures the next site.
---
.../medications/__tests__/card-parts.test.tsx | 55 ++++------------
.../__tests__/medication-card-glp1.test.tsx | 22 +++----
.../card-parts/medication-cycle-status.tsx | 65 ++++++-------------
.../medications/glp1-medication-card.tsx | 27 +-------
4 files changed, 41 insertions(+), 128 deletions(-)
diff --git a/src/components/medications/__tests__/card-parts.test.tsx b/src/components/medications/__tests__/card-parts.test.tsx
index 708362399..4fbea4ab1 100644
--- a/src/components/medications/__tests__/card-parts.test.tsx
+++ b/src/components/medications/__tests__/card-parts.test.tsx
@@ -207,49 +207,18 @@ describe("medication cycle status — open-cycle line", () => {
expect(html).not.toContain("Next dose in");
});
- it("on_track several days out reads the relative-day phrasing in success tone", () => {
- const html = render(
- ,
- );
- expect(html).toContain("Next dose in 4 days");
- expect(html).toContain("text-success");
- expect(html).toContain("lucide-circle-check");
- });
-
- it("on_track reads the same day count when nextDueAt arrives as an ISO string", () => {
- // Over the wire `nextDueAt` is JSON, so it reaches the card as a string
- // even though the type says `Date`. The component must re-hydrate it for
- // the Berlin day-bucketing — a raw string would silently drift the count.
- const iso = inDays(4).toISOString() as unknown as Date;
- const html = render(
- ,
- );
- expect(html).toContain("Next dose in 4 days");
- });
-
- it("on_track one day out reads tomorrow", () => {
- const html = render(
- ,
- );
- expect(html).toContain("Next dose tomorrow");
- expect(html).not.toContain("in 1 days");
- });
-
- it("on_track later today reads today", () => {
- // Six hours past the pinned noon — still the same Berlin calendar day.
- const later = new Date(Date.now() + 6 * 60 * 60 * 1000);
- const html = render(
- ,
- );
- expect(html).toContain("Next dose today");
+ it("on_track renders nothing — the next-dose timing already reads on the card's top slot", () => {
+ // The calm on_track "next dose in N days" phrasing duplicates the
+ // next-intake slot at the top of the card, so the open-cycle line stays
+ // silent for an on-track med; only due / overdue earn a line here.
+ for (const nextDueAt of [inDays(4), inDays(1), new Date(Date.now() + 6 * 60 * 60 * 1000)]) {
+ const html = render(
+ ,
+ );
+ expect(html).toBe("");
+ }
});
it("due reads the amber due-today line", () => {
diff --git a/src/components/medications/__tests__/medication-card-glp1.test.tsx b/src/components/medications/__tests__/medication-card-glp1.test.tsx
index a7130b2f0..c77f983ea 100644
--- a/src/components/medications/__tests__/medication-card-glp1.test.tsx
+++ b/src/components/medications/__tests__/medication-card-glp1.test.tsx
@@ -370,15 +370,11 @@ describe(" — GLP-1 variant rendering", () => {
expect(html).not.toContain("Dosis-Historie");
});
- it("renders the injection-site rotation nudge folded into the upcoming line", () => {
- // The rotation nudge surfaces only when `lastSite` and the recommender
- // disagree. We feed a left-cluster history → the recommender lands on a
- // right-side or thigh/arm site. The nudge is no longer a separate
- // bordered block (which made the GLP-1 card taller than the generic card
- // in a grid row); it is folded into the upcoming-injection line as a
- // "· Recommended next: …" suffix, so the card keeps the same row
- // inventory as the generic card. The last site stays visible on the
- // last-injection line.
+ it("does not render an injection-site recommendation on the card", () => {
+ // The card no longer carries a "recommended next site" nudge — the
+ // recommendation is noise on the at-a-glance surface. Site TRACKING stays
+ // (the last site reads on the last-injection line; the post-dose picker
+ // still captures the next site), only the recommendation copy is gone.
const client = makeClient();
seedCompliance(client, med7p5.id);
seedGlp1Details(client, med7p5.id, {
@@ -401,11 +397,9 @@ describe(" — GLP-1 variant rendering", () => {
client,
);
- // The recommended-next nudge renders in the upcoming line.
- expect(html).toContain("Recommended next:");
- // The standalone bordered rotation block is gone (height-neutral fold).
- expect(html).not.toContain("bg-muted/40 rounded-md border");
- // Last site stays visible on the last-injection line.
+ // No recommendation copy anywhere on the card.
+ expect(html).not.toContain("Recommended next:");
+ // Last site stays visible on the last-injection line (tracking preserved).
expect(html).toContain("Abdomen, lower left");
});
diff --git a/src/components/medications/card-parts/medication-cycle-status.tsx b/src/components/medications/card-parts/medication-cycle-status.tsx
index 8ff46e633..c364b379f 100644
--- a/src/components/medications/card-parts/medication-cycle-status.tsx
+++ b/src/components/medications/card-parts/medication-cycle-status.tsx
@@ -1,30 +1,13 @@
-import { AlertTriangle, CalendarClock, CircleCheck, CircleDashed } from "lucide-react";
+import { AlertTriangle, CalendarClock, CircleDashed } from "lucide-react";
import type { CurrentCycle } from "@/lib/analytics/compliance";
import { useTranslations } from "@/lib/i18n/context";
-import { toBerlinDate } from "@/lib/medications/window-status";
interface MedicationCycleStatusProps {
/** The open-cycle descriptor from `complianceDisplay.currentCycle`. */
cycle: CurrentCycle;
}
-const DAY_MS = 24 * 60 * 60 * 1000;
-
-/**
- * Whole-day delta between two instants, measured on the user's calendar
- * (Europe/Berlin) so "in 2 days" lines up with the day a sparse dose lands on
- * rather than a raw 48-hour count. Positive = future, 0 = today, negative =
- * past. Mirrors the day-bucketing the card already does for its next/last slot.
- */
-function calendarDayDelta(target: Date, now: Date): number {
- const a = toBerlinDate(target);
- const b = toBerlinDate(now);
- const targetMidnight = new Date(a.getFullYear(), a.getMonth(), a.getDate());
- const nowMidnight = new Date(b.getFullYear(), b.getMonth(), b.getDate());
- return Math.round((targetMidnight.getTime() - nowMidnight.getTime()) / DAY_MS);
-}
-
/**
* v1.14.0 — the open-cycle status line for the medication cards.
*
@@ -32,15 +15,20 @@ function calendarDayDelta(target: Date, now: Date): number {
* rolling med (a GLP-1 injection) that is simply between doses has no scored
* cycle in flight, so the percentage alone reads as a scary 0% / 100% that
* says nothing about whether the user is actually on schedule. This line is
- * decoupled from the rate: it reports the open cycle's calm status — when the
- * next dose lands, whether it's due or overdue, or that there's not enough
- * history to score yet.
+ * decoupled from the rate: it reports the open cycle's calm status — whether
+ * the dose is due or overdue, or that there's not enough history to score yet.
+ *
+ * The calm `on_track` "next dose in N days" phrasing is intentionally NOT
+ * rendered here: the upcoming-dose timing already reads on the card's
+ * next-intake slot at the top, so repeating it would be a duplicate. Only the
+ * actionable due / overdue tiers (and the neutral no-closed-cycles note) earn
+ * this line.
*
* Driven by `currentCycle.state` (`on_track` / `due` / `missed` / `none`) plus
* `hasClosedCycles`. Colour follows the same semantic ramp the status pill
- * uses (success / warning / destructive) and pairs every tier with a Lucide
- * glyph so colour-blind users can disambiguate — WCAG 1.4.1 (Use of Color).
- * It complements the compliance number; it does not replace it.
+ * uses (warning / destructive) and pairs every tier with a Lucide glyph so
+ * colour-blind users can disambiguate — WCAG 1.4.1 (Use of Color). It
+ * complements the compliance number; it does not replace it.
*/
export function MedicationCycleStatus({ cycle }: MedicationCycleStatusProps) {
const { t } = useTranslations();
@@ -61,6 +49,12 @@ export function MedicationCycleStatus({ cycle }: MedicationCycleStatusProps) {
);
}
+ // on_track — the next dose simply hasn't come round yet. The upcoming-dose
+ // timing already reads on the card's next-intake slot at the top, so a second
+ // "next dose in N days" line here is a duplicate. Render nothing for the
+ // on-track case; only the actionable due / overdue tiers earn this line.
+ if (cycle.state !== "missed" && cycle.state !== "due") return null;
+
let tone: string;
let icon: React.ReactNode;
let label: string;
@@ -69,30 +63,11 @@ export function MedicationCycleStatus({ cycle }: MedicationCycleStatusProps) {
tone = "text-destructive";
icon = ;
label = t("medications.cycleOverdue");
- } else if (cycle.state === "due") {
+ } else {
+ // due
tone = "text-warning";
icon = ;
label = t("medications.cycleDueToday");
- } else {
- // on_track — the next dose simply hasn't come round yet.
- tone = "text-success";
- icon = ;
- const now = new Date();
- // `nextDueAt` is typed `Date` but arrives as an ISO string over the wire;
- // re-hydrate it so the Berlin day-bucketing (toLocaleString with a
- // timeZone) actually applies — a raw string would silently skip it and
- // drift the count by a day near midnight / off Berlin.
- const days = cycle.nextDueAt
- ? calendarDayDelta(new Date(cycle.nextDueAt), now)
- : null;
- if (days === null || days <= 0) {
- // on_track with the slot today (now < dueAt but same calendar day).
- label = t("medications.cycleNextDoseToday");
- } else if (days === 1) {
- label = t("medications.cycleNextDoseTomorrow");
- } else {
- label = t("medications.cycleNextDoseInDays", { days });
- }
}
return (
diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx
index 1f81a4d12..df75a98a8 100644
--- a/src/components/medications/glp1-medication-card.tsx
+++ b/src/components/medications/glp1-medication-card.tsx
@@ -29,10 +29,7 @@ import {
} from "@/lib/query-keys";
import { formatDateTime, formatTime } from "@/lib/format";
import { getMedicationCategoryLabel } from "@/lib/medications/category-label";
-import {
- nextInjectionSite,
- type InjectionSiteKey,
-} from "@/lib/medications/injection-sites";
+import { type InjectionSiteKey } from "@/lib/medications/injection-sites";
import { LogInjectionSiteDialog } from "@/components/medications/log-injection-site-dialog";
import { useGlobalExcludedInjectionSites } from "@/lib/medications/use-injection-site-prefs";
import {
@@ -324,11 +321,6 @@ export function Glp1MedicationCard({
const recentInjections = details?.recentIntakes ?? [];
const lastSite =
recentInjections.find((i) => i.injectionSite)?.injectionSite ?? null;
- const recommendedNextSite = nextInjectionSite(
- recentInjections
- .map((r) => r.injectionSite)
- .filter(Boolean) as InjectionSiteKey[],
- );
function lastInjectionLabel(): string | null {
if (!medication.lastTakenAt) return null;
@@ -433,23 +425,6 @@ export function Glp1MedicationCard({
— {schedule.dose}
)}
- {/* Rotation nudge — folded into the upcoming-injection line
- instead of a separate bordered block, so the GLP-1 card
- keeps the same row inventory (and height) as the generic
- card. Only when we have a last + recommended site that
- differs from it. */}
- {lastSite &&
- recommendedNextSite &&
- recommendedNextSite !== lastSite && (
-
- {" · "}
- {t("medications.glp1RotationSuggested", {
- site: t(
- `medications.site${siteSuffix(recommendedNextSite)}`,
- ),
- })}
-
- )}
>
) : null
}
From 646ef164aee54e44a4474b7de1a871683a0412e3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 6 Jun 2026 23:25:52 +0200
Subject: [PATCH 08/11] fix(ui): align the select chevron symmetrically with
the value inset
Both select primitives parked the trailing chevron visibly farther from
the right edge than the value text sits from the left. The shadcn trigger
gets a -2 px optical nudge to offset the chevron glyph's internal
padding. The native select drops the OS-drawn arrow (`appearance-none`)
and paints a chevron background at a 12 px right inset mirroring the left
`px-3` padding, kept on the single `