From 161963c82b1c4a0ece477add94da67763847ff10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 20:35:55 +0200 Subject: [PATCH 01/15] Put the passkey and security-key add actions in the card's action row Both cards now end in the one right-aligned action row with the add action as the card's primary, like the authenticator card's set-up button, and both show the same plain empty state. The error line moves above the row so nothing follows it. --- .../security-section/passkey-list-section.tsx | 37 ++++++++++--------- .../security-section/security-keys-card.tsx | 28 +++++++------- 2 files changed, 34 insertions(+), 31 deletions(-) diff --git a/src/components/settings/security-section/passkey-list-section.tsx b/src/components/settings/security-section/passkey-list-section.tsx index 94fa4b4cf..6ccb1f6bc 100644 --- a/src/components/settings/security-section/passkey-list-section.tsx +++ b/src/components/settings/security-section/passkey-list-section.tsx @@ -29,6 +29,8 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; +import { EmptyState } from "@/components/ui/empty-state"; import { formatDate } from "@/lib/format"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -193,11 +195,13 @@ export function PasskeyListSection({ description={t("settings.passkeysDescription")} /> -
+
{!passkeys || passkeys.length === 0 ? ( -

- {t("settings.noPasskeys")} -

+ ) : (
    {passkeys.map((pk) => ( @@ -341,6 +345,16 @@ export function PasskeyListSection({
)} + {msg && !reauthOpen && ( +
+ + {msg} +
+ )} + { @@ -349,11 +363,10 @@ export function PasskeyListSection({ if (open) setMsg(null); }} > -
+ -
+ @@ -475,16 +488,6 @@ export function PasskeyListSection({
- - {msg && !reauthOpen && ( -
- - {msg} -
- )}
); diff --git a/src/components/settings/security-section/security-keys-card.tsx b/src/components/settings/security-section/security-keys-card.tsx index 9d487d5b0..5389b9ed7 100644 --- a/src/components/settings/security-section/security-keys-card.tsx +++ b/src/components/settings/security-section/security-keys-card.tsx @@ -28,6 +28,7 @@ import { Input } from "@/components/ui/input"; import { EmptyState } from "@/components/ui/empty-state"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { ApiError, apiDelete, apiPatch, apiPost } from "@/lib/api/api-fetch"; @@ -197,7 +198,7 @@ export function SecurityKeysCard({ description={t("settings.security.keys.description")} /> -
+
{keys.length === 0 ? ( )} -
+ {error && ( +
+ + {error} +
+ )} + + -
+ { @@ -362,16 +372,6 @@ export function SecurityKeysCard({ error={reauthError} onProof={(proof) => add.mutate(proof)} /> - - {error && ( -
- - {error} -
- )}
); From c90d28e59766a3b8ac721f7412c548d16f67c77e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 20:35:56 +0200 Subject: [PATCH 02/15] Name the admin account and invite cards apart from their pages The users page card is now "Accounts" and the invites card "Invite links", so neither repeats the page title above it, and the token overview reads "API tokens" in sentence case. The role filter pills stay neutral when selected, and the three icon tools on a phone row wrap as one group instead of leaving the sign-out icon on its own line. --- messages/de.json | 8 +- messages/en.json | 10 +-- messages/es.json | 8 +- messages/fr.json | 8 +- messages/it.json | 8 +- messages/ko.json | 8 +- messages/pl.json | 8 +- .../admin/__tests__/sections.test.tsx | 4 +- .../admin/user-management-section.tsx | 87 ++++++++++--------- 9 files changed, 78 insertions(+), 71 deletions(-) diff --git a/messages/de.json b/messages/de.json index 656959442..cfadbd90c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -7377,8 +7377,8 @@ "coachFollowsSwitch": "Folgt dem Coach-Schalter im Bereich Coach." }, "invites": { - "title": "Einladungen", - "description": "Einladungslinks für die Registrierung — funktionieren auch bei geschlossener Registrierung.", + "title": "Einladungslinks", + "description": "Jeder Link lässt eine Registrierung zu, auch wenn sie geschlossen ist.", "create": "Einladung erstellen", "createTitle": "Einladung erstellen", "createDescription": "Der Link erlaubt eine Registrierung auch bei geschlossener Registrierung.", @@ -7424,8 +7424,8 @@ "revokedToast": "Einladung widerrufen.", "revokeError": "Einladung konnte nicht widerrufen werden." }, - "userManagement": "Nutzerverwaltung", - "userManagementDescription": "Konten verwalten: Rollen, Passwort-Resets und erzwungene Abmeldungen.", + "userManagement": "Konten", + "userManagementDescription": "Rollen, Passwort-Resets und erzwungene Abmeldungen für jedes Konto.", "userName": "Name", "userEmail": "E-Mail", "userRole": "Rolle", diff --git a/messages/en.json b/messages/en.json index c97071d00..03ea6e8c7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -7377,8 +7377,8 @@ "coachFollowsSwitch": "Follows the Coach switch in the Coach section." }, "invites": { - "title": "Invites", - "description": "Registration invite links — they work even while registration is closed.", + "title": "Invite links", + "description": "Each link admits a signup even while registration is closed.", "create": "Create invite", "createTitle": "Create invite", "createDescription": "The link admits a signup even while registration is closed.", @@ -7424,8 +7424,8 @@ "revokedToast": "Invite revoked.", "revokeError": "Could not revoke the invite." }, - "userManagement": "User Management", - "userManagementDescription": "Manage accounts: roles, password resets, and forced logouts.", + "userManagement": "Accounts", + "userManagementDescription": "Roles, password resets and forced logouts for every account.", "userName": "Name", "userEmail": "Email", "userRole": "Role", @@ -7446,7 +7446,7 @@ "passwordReset": "Password has been reset", "reset": "Reset", "loadingUsers": "Loading users…", - "apiTokens": "API Tokens", + "apiTokens": "API tokens", "apiTokensDescription": "All active and revoked API tokens across all accounts.", "tokenUser": "User", "tokenName": "Name", diff --git a/messages/es.json b/messages/es.json index 02d1d2fca..207e50d34 100644 --- a/messages/es.json +++ b/messages/es.json @@ -7377,8 +7377,8 @@ "coachFollowsSwitch": "Sigue el interruptor del Coach en la sección Coach." }, "invites": { - "title": "Invitaciones", - "description": "Enlaces de invitación para el registro: funcionan incluso con el registro cerrado.", + "title": "Enlaces de invitación", + "description": "Cada enlace permite un registro aunque el registro esté cerrado.", "create": "Crear invitación", "createTitle": "Crear invitación", "createDescription": "El enlace permite registrarse incluso con el registro cerrado.", @@ -7424,8 +7424,8 @@ "revokedToast": "Invitación revocada.", "revokeError": "No se pudo revocar la invitación." }, - "userManagement": "Gestión de usuarios", - "userManagementDescription": "Gestiona las cuentas: roles, restablecimientos de contraseña y cierres de sesión forzados.", + "userManagement": "Cuentas", + "userManagementDescription": "Roles, restablecimientos de contraseña y cierres de sesión forzados de cada cuenta.", "userName": "Nombre", "userEmail": "Correo electrónico", "userRole": "Rol", diff --git a/messages/fr.json b/messages/fr.json index 0d6b487d1..0f3cd67f7 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -7377,8 +7377,8 @@ "coachFollowsSwitch": "Suit l'interrupteur Coach de la section Coach." }, "invites": { - "title": "Invitations", - "description": "Liens d'invitation pour l'inscription — ils fonctionnent même lorsque les inscriptions sont fermées.", + "title": "Liens d'invitation", + "description": "Chaque lien permet une inscription même lorsque les inscriptions sont fermées.", "create": "Créer une invitation", "createTitle": "Créer une invitation", "createDescription": "Le lien permet une inscription même lorsque les inscriptions sont fermées.", @@ -7424,8 +7424,8 @@ "revokedToast": "Invitation révoquée.", "revokeError": "Impossible de révoquer l'invitation." }, - "userManagement": "Gestion des utilisateurs", - "userManagementDescription": "Gérez les comptes : rôles, réinitialisations de mot de passe et déconnexions forcées.", + "userManagement": "Comptes", + "userManagementDescription": "Rôles, réinitialisations de mot de passe et déconnexions forcées pour chaque compte.", "userName": "Nom", "userEmail": "E-mail", "userRole": "Rôle", diff --git a/messages/it.json b/messages/it.json index 7a091f12f..2ae1490d2 100644 --- a/messages/it.json +++ b/messages/it.json @@ -7377,8 +7377,8 @@ "coachFollowsSwitch": "Segue l'interruttore Coach nella sezione Coach." }, "invites": { - "title": "Inviti", - "description": "Link di invito per la registrazione: funzionano anche quando la registrazione è chiusa.", + "title": "Link di invito", + "description": "Ogni link consente una registrazione anche quando la registrazione è chiusa.", "create": "Crea invito", "createTitle": "Crea invito", "createDescription": "Il link consente una registrazione anche quando la registrazione è chiusa.", @@ -7424,8 +7424,8 @@ "revokedToast": "Invito revocato.", "revokeError": "Impossibile revocare l'invito." }, - "userManagement": "Gestione utenti", - "userManagementDescription": "Gestisci gli account: ruoli, reset delle password e disconnessioni forzate.", + "userManagement": "Account", + "userManagementDescription": "Ruoli, reset delle password e disconnessioni forzate per ogni account.", "userName": "Nome", "userEmail": "E-mail", "userRole": "Ruolo", diff --git a/messages/ko.json b/messages/ko.json index 5f1896298..21cffb63a 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -7353,8 +7353,8 @@ "coachFollowsSwitch": "코치 섹션의 코치 스위치를 따라요." }, "invites": { - "title": "초대", - "description": "가입 초대 링크 — 회원가입이 닫혀 있어도 동작해요.", + "title": "초대 링크", + "description": "링크마다 회원가입이 닫혀 있어도 가입할 수 있어요.", "create": "초대 만들기", "createTitle": "초대 만들기", "createDescription": "회원가입이 닫혀 있어도 이 링크로는 가입할 수 있어요.", @@ -7400,8 +7400,8 @@ "revokedToast": "초대를 취소했어요.", "revokeError": "초대를 취소하지 못했어요." }, - "userManagement": "사용자 관리", - "userManagementDescription": "계정 관리: 역할, 비밀번호 재설정, 강제 로그아웃.", + "userManagement": "계정", + "userManagementDescription": "계정마다 역할, 비밀번호 재설정, 강제 로그아웃을 관리해요.", "userName": "이름", "userEmail": "이메일", "userRole": "역할", diff --git a/messages/pl.json b/messages/pl.json index 9dd315df2..53ba3023f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -7377,8 +7377,8 @@ "coachFollowsSwitch": "Podlega przełącznikowi Coach w sekcji Coach." }, "invites": { - "title": "Zaproszenia", - "description": "Linki z zaproszeniem do rejestracji — działają nawet przy zamkniętej rejestracji.", + "title": "Linki z zaproszeniem", + "description": "Każdy link pozwala na rejestrację nawet przy zamkniętej rejestracji.", "create": "Utwórz zaproszenie", "createTitle": "Utwórz zaproszenie", "createDescription": "Link umożliwia rejestrację nawet przy zamkniętej rejestracji.", @@ -7424,8 +7424,8 @@ "revokedToast": "Zaproszenie unieważnione.", "revokeError": "Nie udało się unieważnić zaproszenia." }, - "userManagement": "Zarządzanie użytkownikami", - "userManagementDescription": "Zarządzaj kontami: role, resety haseł i wymuszone wylogowania.", + "userManagement": "Konta", + "userManagementDescription": "Role, resety haseł i wymuszone wylogowania dla każdego konta.", "userName": "Nazwa", "userEmail": "E-mail", "userRole": "Rola", diff --git a/src/components/admin/__tests__/sections.test.tsx b/src/components/admin/__tests__/sections.test.tsx index bc6ee08a0..3d3449673 100644 --- a/src/components/admin/__tests__/sections.test.tsx +++ b/src/components/admin/__tests__/sections.test.tsx @@ -102,7 +102,7 @@ describe("admin sections — SSR smoke", () => { it(" renders with filter pills", () => { const html = render(); - expect(html).toContain("User Management"); + expect(html).toContain("Accounts"); // Filter pills painted from i18n expect(html).toContain("All"); expect(html).toContain("Admins"); @@ -110,7 +110,7 @@ describe("admin sections — SSR smoke", () => { it(" renders", () => { const html = render(); - expect(html).toContain("API Tokens"); + expect(html).toContain("API tokens"); }); it(" renders", () => { diff --git a/src/components/admin/user-management-section.tsx b/src/components/admin/user-management-section.tsx index 8c2f746c0..e6b0ff13a 100644 --- a/src/components/admin/user-management-section.tsx +++ b/src/components/admin/user-management-section.tsx @@ -231,45 +231,50 @@ export function UserManagementSection() {
- {/* Date of birth + language share one paired grid row so the - profile form keeps a single rhythm (every row two cells - wide on sm+). Date of birth is the bottom of the - biological-profile block; language is the only UI - preference on this card. They sit together to close the - "single-cell row" gap that broke the form's grid. */} + {/* Date of birth + timezone share one paired grid row. The + timezone stays on this card because it saves with the form + (its own PUT runs beside the profile PUT); the display + preferences that save on change live in their own card below. */}
@@ -544,44 +542,7 @@ export function AccountSection() {

-
- - {/* While a switch waits on its message bundle (dynamic - import per locale), show the target value and lock the - control — the context only flips locale + strings - together once the bundle arrived. */} - setLocale(e.target.value as Locale)} - > - {locales.map((loc) => ( - - ))} - -

- {t("settings.languageDescription")} -

-
-
- - {/* Timezone, unit system, glucose unit and the date/hour formats - share one grid block — all personal display preferences (like - language above). The glucose unit is its own dropdown rather - than a branch of the unit system because metric countries are - split on which unit they read glucose in. Every dropdown here - PATCHes its own endpoint on change; the timezone saves through - the form's submit handler. */} -
- - - -
{/* v1.7.0 — optional patient-identity fields surfaced on the @@ -702,6 +663,8 @@ export function AccountSection() { {recentProof.dialog} + + {/* v1.18.0 (S5) — injection-site exclusions moved to the dedicated Medikamente settings section, where every medication-specific preference now lives. */} @@ -734,3 +697,59 @@ export function AccountSection() { ); } + +/** + * Language, units and formats: every control here saves the moment it + * changes (each PATCHes its own endpoint), so they sit in a card with no save + * button. Inside the profile form they read as if the form's Save covered + * them, and a person who changed the language and then cancelled the form + * found it changed anyway. + */ +export function DisplayPreferencesCard({ + isAuthenticated, +}: { + isAuthenticated: boolean; +}) { + const { t, locale, setLocale, pendingLocale } = useTranslations(); + return ( + + + {/* The glucose unit is its own dropdown rather than a branch of the + unit system because metric countries are split on which unit they + read glucose in. */} +
+
+ + {/* While a switch waits on its message bundle (dynamic import per + locale), show the target value and lock the control — the + context only flips locale + strings together once the bundle + arrived. */} + setLocale(e.target.value as Locale)} + > + {locales.map((loc) => ( + + ))} + +

+ {t("settings.languageDescription")} +

+
+ + + + +
+
+ ); +} From 834803ff2163113adfe3194565d094aa8a1f1082 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 20:42:35 +0200 Subject: [PATCH 06/15] Page the admin app logs fifty rows at a time The buffer answers with up to ~500 events and the table painted all of them. It now shows fifty per page with the login overview's pager, resets to the first page when a filter changes, and clamps the page when a refetch returns fewer events. --- .../__tests__/app-log-pagination.test.tsx | 65 ++++++++++++++++ .../admin/app-log-preview-section.tsx | 75 +++++++++++++++++-- 2 files changed, 134 insertions(+), 6 deletions(-) create mode 100644 src/components/admin/__tests__/app-log-pagination.test.tsx diff --git a/src/components/admin/__tests__/app-log-pagination.test.tsx b/src/components/admin/__tests__/app-log-pagination.test.tsx new file mode 100644 index 000000000..23534725e --- /dev/null +++ b/src/components/admin/__tests__/app-log-pagination.test.tsx @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +/** + * `/admin/app-logs` pages the buffered events fifty at a time, like the + * login overview, instead of painting up to ~500 rows in one table. + */ + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/admin/app-logs", +})); + +const events = Array.from({ length: 120 }, (_, i) => ({ + request_id: `req-${i}`, + trace_id: `trace${String(i).padStart(11, "0")}`, + level: "info" as const, + timestamp: "2026-05-10T09:00:00Z", + duration_ms: 12, + action: { name: `event.number.${i}` }, + kind: "request", +})); + +let served = events; + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ + data: { events: served, meta: { total: served.length, bufferMax: 500 } }, + isLoading: false, + refetch: vi.fn(), + isFetching: false, + }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { + APP_LOG_PAGE_SIZE, + AppLogPreviewSection, +} from "../app-log-preview-section"; + +function render() { + return renderToStaticMarkup( + + + , + ); +} + +describe("AppLogPreviewSection — pagination", () => { + it("paints one page of fifty rows and a pager", () => { + served = events; + const html = render(); + expect(APP_LOG_PAGE_SIZE).toBe(50); + expect(html.match(/event.number.0<"); + expect(html).not.toContain(">event.number.50<"); + expect(html).toContain('data-testid="app-log-pagination"'); + expect(html).toContain("Page 1 of 3"); + }); + + it("shows no pager when everything fits on one page", () => { + served = events.slice(0, 10); + expect(render()).not.toContain('data-testid="app-log-pagination"'); + }); +}); diff --git a/src/components/admin/app-log-preview-section.tsx b/src/components/admin/app-log-preview-section.tsx index 8f23a5bc4..b043500c3 100644 --- a/src/components/admin/app-log-preview-section.tsx +++ b/src/components/admin/app-log-preview-section.tsx @@ -55,6 +55,13 @@ import { apiGet } from "@/lib/api/api-fetch"; type RangePreset = "15m" | "1h" | "6h" | "all"; +/** + * Rows per page. The buffer answers with up to ~500 events at once; fifty a + * page matches the login overview's default and keeps the table one screen + * of scrolling instead of ten. + */ +export const APP_LOG_PAGE_SIZE = 50; + interface AppLogsResponse { events: WideEvent[]; meta: { @@ -92,6 +99,7 @@ export function AppLogPreviewSection() { const [level, setLevel] = useState("__all__"); const [range, setRange] = useState("1h"); const [selected, setSelected] = useState(null); + const [page, setPage] = useState(1); const params = useMemo(() => { const p = new URLSearchParams(); @@ -114,6 +122,14 @@ export function AppLogPreviewSection() { }); const events = data?.events ?? []; + // The buffer refetches every 30 s and can shrink under a narrower filter, + // so the page is clamped on read rather than trusted. + const lastPage = Math.max(1, Math.ceil(events.length / APP_LOG_PAGE_SIZE)); + const currentPage = Math.min(page, lastPage); + const pageEvents = events.slice( + (currentPage - 1) * APP_LOG_PAGE_SIZE, + currentPage * APP_LOG_PAGE_SIZE, + ); return ( @@ -133,19 +149,28 @@ export function AppLogPreviewSection() { type="search" placeholder={t("admin.section.app-logs.filterTraceId")} value={traceId} - onChange={(e) => setTraceId(e.target.value)} + onChange={(e) => { + setTraceId(e.target.value); + setPage(1); + }} aria-label={t("admin.section.app-logs.filterTraceId")} /> setActionFilter(e.target.value)} + onChange={(e) => { + setActionFilter(e.target.value); + setPage(1); + }} aria-label={t("admin.section.app-logs.filterAction")} /> - { + setRange(v as RangePreset); + setPage(1); + }} + > - {events.map((event, i) => { + {pageEvents.map((event, i) => { const actionLabel = event.action?.name ?? (`${event.http?.method ?? ""} ${event.http?.path ?? ""}`.trim() || @@ -290,7 +321,7 @@ export function AppLogPreviewSection() {
@@ -299,6 +330,38 @@ export function AppLogPreviewSection() { bufferMax: data?.meta.bufferMax ?? 500, })} + {/* Same pager as the login overview. */} + {lastPage > 1 ? ( +
+ + + {t("admin.section.auditLog.pageOf", { + page: currentPage, + total: lastPage, + })} + + +
+ ) : null}
)} From c4272343e364bcb19231a9005102175ff1e60630 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 20:45:31 +0200 Subject: [PATCH 07/15] Collapse the admin per-column encryption table by default A fresh install lists well over a hundred encrypted columns, nearly all empty, and the open table pushed every other encryption card off the page. The card now opens on a toggle that names how many columns hold rows, lists only those when expanded, and offers the full list behind a second toggle. --- messages/de.json | 7 +- messages/en.json | 7 +- messages/es.json | 7 +- messages/fr.json | 7 +- messages/it.json | 7 +- messages/ko.json | 7 +- messages/pl.json | 7 +- .../encryption-legacy-badge.test.tsx | 61 +++++- src/components/admin/encryption-section.tsx | 182 +++++++++++++----- 9 files changed, 230 insertions(+), 62 deletions(-) diff --git a/messages/de.json b/messages/de.json index 2baa2f32e..637089669 100644 --- a/messages/de.json +++ b/messages/de.json @@ -7889,7 +7889,12 @@ "backupsNone": "Noch keine Sicherung hat die Schlüssel festgehalten, die sie braucht.", "backupsStillNeed": "Alle Zeilen sind rotiert, aber Sicherungen brauchen noch {keys}", "backupsUnrecordedBadge": "Alle Zeilen sind rotiert, aber ältere Sicherungen können noch einen ausgemusterten Schlüssel brauchen", - "backupsDetail": "Die Rotation verschlüsselt jede Zeile neu, aber eine Sicherung behält den Schlüssel, unter dem ihr Inhalt geschrieben wurde, und ihre Wiederherstellung braucht ihn. Behalte einen ausgemusterten Schlüssel in ENCRYPTION_KEYS, bis er unten nicht mehr aufgeführt ist." + "backupsDetail": "Die Rotation verschlüsselt jede Zeile neu, aber eine Sicherung behält den Schlüssel, unter dem ihr Inhalt geschrieben wurde, und ihre Wiederherstellung braucht ihn. Behalte einen ausgemusterten Schlüssel in ENCRYPTION_KEYS, bis er unten nicht mehr aufgeführt ist.", + "columnsShow": "Tabelle anzeigen ({count} mit Zeilen)", + "columnsHide": "Tabelle ausblenden", + "columnsShowAll": "Alle {count} Spalten anzeigen", + "columnsShowWithRows": "Nur Spalten mit Zeilen", + "columnsNoneWithRows": "Noch keine verschlüsselte Spalte enthält Zeilen." }, "maintenance": { "title": "Pflege der Messwert-Tabelle", diff --git a/messages/en.json b/messages/en.json index 3f2d82afa..185bd2226 100644 --- a/messages/en.json +++ b/messages/en.json @@ -7889,7 +7889,12 @@ "backupsNone": "No backup has recorded the keys it needs yet.", "backupsStillNeed": "Every row is rotated, but backups still need {keys}", "backupsUnrecordedBadge": "Every row is rotated, but older backups may still need a retired key", - "backupsDetail": "Rotation re-encrypts every row, but a backup keeps the key its content was written under, and restoring it needs that key. Keep a retired key in ENCRYPTION_KEYS until nothing below lists it." + "backupsDetail": "Rotation re-encrypts every row, but a backup keeps the key its content was written under, and restoring it needs that key. Keep a retired key in ENCRYPTION_KEYS until nothing below lists it.", + "columnsShow": "Show the table ({count} with rows)", + "columnsHide": "Hide the table", + "columnsShowAll": "Show all {count} columns", + "columnsShowWithRows": "Only columns with rows", + "columnsNoneWithRows": "No encrypted column holds rows yet." }, "maintenance": { "title": "Measurement table maintenance", diff --git a/messages/es.json b/messages/es.json index be3c7baf3..787114de2 100644 --- a/messages/es.json +++ b/messages/es.json @@ -7889,7 +7889,12 @@ "backupsNone": "Ninguna copia ha registrado todavía las claves que necesita.", "backupsStillNeed": "Todas las filas están rotadas, pero las copias aún necesitan {keys}", "backupsUnrecordedBadge": "Todas las filas están rotadas, pero copias antiguas pueden necesitar aún una clave retirada", - "backupsDetail": "La rotación vuelve a cifrar cada fila, pero una copia conserva la clave con la que se escribió su contenido, y restaurarla necesita esa clave. Mantén una clave retirada en ENCRYPTION_KEYS hasta que no aparezca abajo." + "backupsDetail": "La rotación vuelve a cifrar cada fila, pero una copia conserva la clave con la que se escribió su contenido, y restaurarla necesita esa clave. Mantén una clave retirada en ENCRYPTION_KEYS hasta que no aparezca abajo.", + "columnsShow": "Mostrar la tabla ({count} con filas)", + "columnsHide": "Ocultar la tabla", + "columnsShowAll": "Mostrar las {count} columnas", + "columnsShowWithRows": "Solo columnas con filas", + "columnsNoneWithRows": "Ninguna columna cifrada contiene filas todavía." }, "maintenance": { "title": "Mantenimiento de la tabla de mediciones", diff --git a/messages/fr.json b/messages/fr.json index b6cf1a77b..7803ab4f4 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -7889,7 +7889,12 @@ "backupsNone": "Aucune sauvegarde n'a encore enregistré les clés dont elle a besoin.", "backupsStillNeed": "Toutes les lignes sont renouvelées, mais des sauvegardes ont encore besoin de {keys}", "backupsUnrecordedBadge": "Toutes les lignes sont renouvelées, mais d'anciennes sauvegardes peuvent encore avoir besoin d'une clé retirée", - "backupsDetail": "La rotation rechiffre chaque ligne, mais une sauvegarde garde la clé sous laquelle son contenu a été écrit, et sa restauration en a besoin. Gardez une clé retirée dans ENCRYPTION_KEYS tant qu'elle figure ci-dessous." + "backupsDetail": "La rotation rechiffre chaque ligne, mais une sauvegarde garde la clé sous laquelle son contenu a été écrit, et sa restauration en a besoin. Gardez une clé retirée dans ENCRYPTION_KEYS tant qu'elle figure ci-dessous.", + "columnsShow": "Afficher le tableau ({count} avec des lignes)", + "columnsHide": "Masquer le tableau", + "columnsShowAll": "Afficher les {count} colonnes", + "columnsShowWithRows": "Seulement les colonnes avec des lignes", + "columnsNoneWithRows": "Aucune colonne chiffrée ne contient encore de lignes." }, "maintenance": { "title": "Maintenance de la table des mesures", diff --git a/messages/it.json b/messages/it.json index d3a70449a..e959745d9 100644 --- a/messages/it.json +++ b/messages/it.json @@ -7889,7 +7889,12 @@ "backupsNone": "Nessun backup ha ancora registrato le chiavi di cui ha bisogno.", "backupsStillNeed": "Tutte le righe sono ruotate, ma i backup hanno ancora bisogno di {keys}", "backupsUnrecordedBadge": "Tutte le righe sono ruotate, ma backup più vecchi potrebbero ancora richiedere una chiave ritirata", - "backupsDetail": "La rotazione cifra di nuovo ogni riga, ma un backup conserva la chiave con cui è stato scritto il suo contenuto, e ripristinarlo richiede quella chiave. Tieni una chiave ritirata in ENCRYPTION_KEYS finché compare qui sotto." + "backupsDetail": "La rotazione cifra di nuovo ogni riga, ma un backup conserva la chiave con cui è stato scritto il suo contenuto, e ripristinarlo richiede quella chiave. Tieni una chiave ritirata in ENCRYPTION_KEYS finché compare qui sotto.", + "columnsShow": "Mostra la tabella ({count} con righe)", + "columnsHide": "Nascondi la tabella", + "columnsShowAll": "Mostra tutte le {count} colonne", + "columnsShowWithRows": "Solo colonne con righe", + "columnsNoneWithRows": "Nessuna colonna cifrata contiene ancora righe." }, "maintenance": { "title": "Manutenzione della tabella delle misurazioni", diff --git a/messages/ko.json b/messages/ko.json index 8c101e880..b6abad7a8 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -7865,7 +7865,12 @@ "backupsNone": "아직 필요한 키를 기록한 백업이 없습니다.", "backupsStillNeed": "모든 행이 회전되었지만 백업에 아직 {keys}가 필요합니다", "backupsUnrecordedBadge": "모든 행이 회전되었지만 이전 백업에는 아직 폐기한 키가 필요할 수 있습니다", - "backupsDetail": "회전은 모든 행을 다시 암호화하지만, 백업은 내용이 기록된 키를 그대로 유지하며 복원에는 그 키가 필요합니다. 아래에 더 이상 표시되지 않을 때까지 폐기한 키를 ENCRYPTION_KEYS에 유지하세요." + "backupsDetail": "회전은 모든 행을 다시 암호화하지만, 백업은 내용이 기록된 키를 그대로 유지하며 복원에는 그 키가 필요합니다. 아래에 더 이상 표시되지 않을 때까지 폐기한 키를 ENCRYPTION_KEYS에 유지하세요.", + "columnsShow": "표 보기 (행 있음 {count}개)", + "columnsHide": "표 숨기기", + "columnsShowAll": "열 {count}개 모두 보기", + "columnsShowWithRows": "행이 있는 열만", + "columnsNoneWithRows": "아직 행이 있는 암호화 열이 없어요." }, "maintenance": { "title": "측정값 테이블 유지 관리", diff --git a/messages/pl.json b/messages/pl.json index d78363ee2..a6f21206f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -7889,7 +7889,12 @@ "backupsNone": "Żadna kopia nie zapisała jeszcze kluczy, których potrzebuje.", "backupsStillNeed": "Wszystkie wiersze są zrotowane, ale kopie nadal potrzebują {keys}", "backupsUnrecordedBadge": "Wszystkie wiersze są zrotowane, ale starsze kopie mogą nadal potrzebować wycofanego klucza", - "backupsDetail": "Rotacja ponownie szyfruje każdy wiersz, ale kopia zachowuje klucz, którym zapisano jej zawartość, a jej przywrócenie wymaga tego klucza. Trzymaj wycofany klucz w ENCRYPTION_KEYS, dopóki widnieje poniżej." + "backupsDetail": "Rotacja ponownie szyfruje każdy wiersz, ale kopia zachowuje klucz, którym zapisano jej zawartość, a jej przywrócenie wymaga tego klucza. Trzymaj wycofany klucz w ENCRYPTION_KEYS, dopóki widnieje poniżej.", + "columnsShow": "Pokaż tabelę (z wierszami: {count})", + "columnsHide": "Ukryj tabelę", + "columnsShowAll": "Pokaż wszystkie kolumny ({count})", + "columnsShowWithRows": "Tylko kolumny z wierszami", + "columnsNoneWithRows": "Żadna zaszyfrowana kolumna nie zawiera jeszcze wierszy." }, "maintenance": { "title": "Konserwacja tabeli pomiarów", diff --git a/src/components/admin/__tests__/encryption-legacy-badge.test.tsx b/src/components/admin/__tests__/encryption-legacy-badge.test.tsx index 2043f5049..07c461003 100644 --- a/src/components/admin/__tests__/encryption-legacy-badge.test.tsx +++ b/src/components/admin/__tests__/encryption-legacy-badge.test.tsx @@ -23,7 +23,7 @@ vi.mock("@tanstack/react-query", () => ({ vi.mock("../key-backup-card", () => ({ KeyBackupCard: () => null })); import { I18nProvider } from "@/lib/i18n/context"; -import { EncryptionSection } from "../encryption-section"; +import { ColumnCoverage, EncryptionSection } from "../encryption-section"; function statusWith(configuredKeyCount: number) { return { @@ -77,9 +77,64 @@ describe("encryption coverage — the legacy-key badge", () => { }); it("names the per-column table as a focusable scroll region", () => { - status.current = statusWith(1); - expect(render()).toMatch( + const html = renderToStaticMarkup( + + + , + ); + expect(html).toMatch( /role="region"[^>]*tabindex="0"|tabindex="0"[^>]*role="region"/, ); }); }); + +function column(model: string, field: string, total: number) { + return { + model, + field, + kind: "string" as const, + total, + byKeyId: { v2: total }, + legacy: 0, + }; +} + +describe("encryption coverage — the per-column table", () => { + const columns = [ + column("User", "withRows", 4), + column("User", "empty", 0), + column("Note", "alsoEmpty", 0), + ]; + + function renderCoverage(initiallyOpen: boolean) { + return renderToStaticMarkup( + + + , + ); + } + + it("starts collapsed on the page, naming how many columns hold rows", () => { + status.current = { ...statusWith(1), columns }; + const html = render(); + expect(html).toContain('data-testid="encryption-columns-toggle"'); + expect(html).toContain('aria-expanded="false"'); + expect(html).toContain("Show the table (1 with rows)"); + expect(html).not.toContain('data-slot="encryption-column-rows"'); + }); + + it("lists only columns with rows when opened, with a toggle for all", () => { + const html = renderCoverage(true); + expect(html).toContain("User.withRows"); + expect(html).not.toContain("User.empty"); + expect(html).toContain("Show all 3 columns"); + }); +}); diff --git a/src/components/admin/encryption-section.tsx b/src/components/admin/encryption-section.tsx index e2755e3af..8879cea70 100644 --- a/src/components/admin/encryption-section.tsx +++ b/src/components/admin/encryption-section.tsx @@ -12,9 +12,11 @@ * which case the documented CLI is the path). */ +import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Archive, + ChevronDown, KeyRound, Loader2, RotateCw, @@ -360,63 +362,139 @@ export function EncryptionSection() { title={t("admin.section.encryption.columnsTitle")} description={t("admin.section.encryption.columnsDescription")} /> - {/* Focusable, named scroll region: on a phone the table scrolls - sideways, and a keyboard user has to be able to reach it - (axe scrollable-region-focusable). */} -
- - - - - - - - - - - {columns.map((c) => { - const active = c.byKeyId[s.activeKeyId] ?? 0; - const stale = c.total - active; - return ( - - - - - - - ); - })} - -
- {t("admin.section.encryption.colColumn")} - - {t("admin.section.encryption.colTotal")} - - {t("admin.section.encryption.colActive")} - - {t("admin.section.encryption.colStale")} -
- {c.model}.{c.field} - {c.total.toLocaleString()}{active.toLocaleString()} - {stale > 0 ? ( - - {stale.toLocaleString()} - - ) : ( - 0 - )} -
-
+
); } +/** + * The per-column table, collapsed by default. A fresh install lists well over + * a hundred encrypted columns, nearly all of them empty, and the table pushed + * every other card off the page. Opened, it shows the columns that hold rows + * (the ones a rotation has to touch), with a toggle for the full list. + */ +export function ColumnCoverage({ + columns, + activeKeyId, + initiallyOpen = false, +}: { + columns: ColumnScan[]; + activeKeyId: string; + /** Start expanded; the page always starts collapsed. */ + initiallyOpen?: boolean; +}) { + const { t } = useTranslations(); + const [open, setOpen] = useState(initiallyOpen); + const [showAll, setShowAll] = useState(false); + const withRows = columns.filter((c) => c.total > 0); + const shown = showAll ? columns : withRows; + + return ( +
+ + {open ? ( +
+ {shown.length === 0 ? ( +

+ {t("admin.section.encryption.columnsNoneWithRows")} +

+ ) : ( + /* Focusable, named scroll region: on a phone the table scrolls + sideways, and a keyboard user has to be able to reach it + (axe scrollable-region-focusable). */ +
+ + + + + + + + + + + {shown.map((c) => { + const active = c.byKeyId[activeKeyId] ?? 0; + const stale = c.total - active; + return ( + + + + + + + ); + })} + +
+ {t("admin.section.encryption.colColumn")} + + {t("admin.section.encryption.colTotal")} + + {t("admin.section.encryption.colActive")} + + {t("admin.section.encryption.colStale")} +
+ {c.model}.{c.field} + + {c.total.toLocaleString()} + {active.toLocaleString()} + {stale > 0 ? ( + + {stale.toLocaleString()} + + ) : ( + 0 + )} +
+
+ )} + {withRows.length < columns.length ? ( + + ) : null} +
+ ) : null} +
+ ); +} + function Stat({ label, value }: { label: string; value: string }) { return ; } From f64816826647cc679faf51c413b0f7c7f74a8749 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 20:46:51 +0200 Subject: [PATCH 08/15] Derive the privacy page's stored-data list from the module registry "What's stored" was six hand-kept lines that never named documents, vaccinations, the cycle or workouts. It now names every module that keeps records, read off the registry so a new module joins without an edit, beside fixed lines for measurements, the profile, integration tokens and account security, in foreground text since it is the content of the card. --- messages/de.json | 9 ++- messages/en.json | 9 ++- messages/es.json | 9 ++- messages/fr.json | 9 ++- messages/it.json | 9 ++- messages/ko.json | 9 ++- messages/pl.json | 9 ++- .../__tests__/privacy-stored-list.test.tsx | 59 +++++++++++++++++++ src/components/settings/privacy-section.tsx | 34 +++++++++-- 9 files changed, 116 insertions(+), 40 deletions(-) create mode 100644 src/components/settings/__tests__/privacy-stored-list.test.tsx diff --git a/messages/de.json b/messages/de.json index 637089669..7798c950e 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6987,12 +6987,11 @@ }, "stored": { "title": "Was gespeichert wird", - "measurements": "Messwerte (Gewicht, Blutdruck, Puls, Glukose, Schlaf, Körperzusammensetzung)", - "medications": "Medikamente und Einnahmeverlauf", - "moodLabs": "Stimmungseinträge und Laborwerte", - "coach": "Coach-Unterhaltungen", + "measurements": "Messwerte: Gewicht, Blutdruck, Puls und jeder weitere Wert, den du oder ein Gerät erfasst", "integrations": "Integrations-Tokens (verschlüsselt)", - "security": "Konto- und Sicherheitsaktivität (Sitzungen, Anmeldeprotokoll)" + "security": "Konto- und Sicherheitsaktivität (Sitzungen, Anmeldeprotokoll)", + "profile": "Dein Profil, deine Anamnese und die Angaben für die Gesundheitsakte", + "modules": "Einträge der Module: {list}" }, "retention": { "title": "Aufbewahrung", diff --git a/messages/en.json b/messages/en.json index 185bd2226..476b9030e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6987,12 +6987,11 @@ }, "stored": { "title": "What's stored", - "measurements": "Measurements (weight, blood pressure, pulse, glucose, sleep, body composition)", - "medications": "Medications and intake history", - "moodLabs": "Mood entries and lab results", - "coach": "Coach conversations", + "measurements": "Measurements: weight, blood pressure, pulse and every other value you or a device record", "integrations": "Integration tokens (encrypted)", - "security": "Account and security activity (sessions, sign-in log)" + "security": "Account and security activity (sessions, sign-in log)", + "profile": "Your profile, medical history and health-record details", + "modules": "Records of each module: {list}" }, "retention": { "title": "Retention", diff --git a/messages/es.json b/messages/es.json index 787114de2..7bc772cdd 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6987,12 +6987,11 @@ }, "stored": { "title": "Qué se almacena", - "measurements": "Mediciones (peso, presión arterial, pulso, glucosa, sueño, composición corporal)", - "medications": "Medicamentos e historial de tomas", - "moodLabs": "Entradas de estado de ánimo y resultados de laboratorio", - "coach": "Conversaciones con el asistente", + "measurements": "Mediciones: peso, presión arterial, pulso y cualquier otro valor que registres tú o un dispositivo", "integrations": "Tokens de integración (cifrados)", - "security": "Actividad de la cuenta y de seguridad (sesiones, registro de inicio de sesión)" + "security": "Actividad de la cuenta y de seguridad (sesiones, registro de inicio de sesión)", + "profile": "Tu perfil, tus antecedentes médicos y los datos del historial de salud", + "modules": "Registros de cada módulo: {list}" }, "retention": { "title": "Retención", diff --git a/messages/fr.json b/messages/fr.json index 7803ab4f4..d13999e72 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6987,12 +6987,11 @@ }, "stored": { "title": "Ce qui est stocké", - "measurements": "Mesures (poids, tension, pouls, glycémie, sommeil, composition corporelle)", - "medications": "Médicaments et historique des prises", - "moodLabs": "Entrées d'humeur et résultats de laboratoire", - "coach": "Conversations avec le coach", + "measurements": "Mesures : poids, tension, pouls et toute autre valeur enregistrée par vous ou un appareil", "integrations": "Jetons d'intégration (chiffrés)", - "security": "Activité du compte et de sécurité (sessions, journal de connexion)" + "security": "Activité du compte et de sécurité (sessions, journal de connexion)", + "profile": "Votre profil, vos antécédents médicaux et les informations du dossier de santé", + "modules": "Données de chaque module : {list}" }, "retention": { "title": "Conservation", diff --git a/messages/it.json b/messages/it.json index e959745d9..e2d3097cb 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6987,12 +6987,11 @@ }, "stored": { "title": "Cosa viene memorizzato", - "measurements": "Misurazioni (peso, pressione, polso, glicemia, sonno, composizione corporea)", - "medications": "Farmaci e cronologia delle assunzioni", - "moodLabs": "Voci dell'umore e referti di laboratorio", - "coach": "Conversazioni con il coach", + "measurements": "Misurazioni: peso, pressione, battito e ogni altro valore registrato da te o da un dispositivo", "integrations": "Token di integrazione (cifrati)", - "security": "Attività dell'account e di sicurezza (sessioni, registro degli accessi)" + "security": "Attività dell'account e di sicurezza (sessioni, registro degli accessi)", + "profile": "Il tuo profilo, l'anamnesi e i dati della cartella sanitaria", + "modules": "Dati di ogni modulo: {list}" }, "retention": { "title": "Conservazione", diff --git a/messages/ko.json b/messages/ko.json index b6abad7a8..933ed797e 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -6987,12 +6987,11 @@ }, "stored": { "title": "저장되는 항목", - "measurements": "측정값 (체중, 혈압, 맥박, 혈당, 수면, 체성분)", - "medications": "약과 복용 기록", - "moodLabs": "기분 기록과 검사 결과", - "coach": "코치 대화", + "measurements": "측정값: 체중, 혈압, 맥박과 직접 또는 기기로 기록한 모든 값", "integrations": "연동 토큰 (암호화됨)", - "security": "계정·보안 활동 (세션, 로그인 기록)" + "security": "계정·보안 활동 (세션, 로그인 기록)", + "profile": "프로필, 병력, 건강 기록 정보", + "modules": "모듈별 기록: {list}" }, "retention": { "title": "보관 기간", diff --git a/messages/pl.json b/messages/pl.json index a6f21206f..733555615 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6987,12 +6987,11 @@ }, "stored": { "title": "Co jest przechowywane", - "measurements": "Pomiary (waga, ciśnienie, tętno, glukoza, sen, skład ciała)", - "medications": "Leki i historia przyjmowania", - "moodLabs": "Wpisy nastroju i wyniki badań laboratoryjnych", - "coach": "Rozmowy z asystentem", + "measurements": "Pomiary: waga, ciśnienie, tętno i każda inna wartość zapisana przez ciebie lub urządzenie", "integrations": "Tokeny integracji (szyfrowane)", - "security": "Aktywność konta i zabezpieczeń (sesje, dziennik logowań)" + "security": "Aktywność konta i zabezpieczeń (sesje, dziennik logowań)", + "profile": "Twój profil, wywiad medyczny i dane dokumentacji zdrowotnej", + "modules": "Wpisy poszczególnych modułów: {list}" }, "retention": { "title": "Retencja", diff --git a/src/components/settings/__tests__/privacy-stored-list.test.tsx b/src/components/settings/__tests__/privacy-stored-list.test.tsx new file mode 100644 index 000000000..b81c53ccc --- /dev/null +++ b/src/components/settings/__tests__/privacy-stored-list.test.tsx @@ -0,0 +1,59 @@ +/** + * Settings → Data & privacy: "What's stored" is read off the module registry, + * so every module that keeps records is named and a new one joins the list + * without an edit here. + */ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ data: undefined, isLoading: true, isError: false }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), + useMutation: () => ({ mutate: vi.fn(), isPending: false }), +})); +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ user: null, isAuthenticated: true, isLoading: false }), +})); +vi.mock("@/components/settings/security-sessions-card", () => ({ + SecuritySessionsCard: () => null, +})); +vi.mock("@/components/settings/security-activity-card", () => ({ + SecurityActivityCard: () => null, +})); +vi.mock("@/components/settings/trusted-devices-card", () => ({ + TrustedDevicesCard: () => null, +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { MODULE_KEYS, MODULE_REGISTRY } from "@/lib/modules/registry"; +import { PrivacySection, storedModuleLabelKeys } from "../privacy-section"; + +function render(): string { + return renderToStaticMarkup( + + + , + ); +} + +describe("Data & privacy — what's stored", () => { + it("names every module that keeps records", () => { + const keys = storedModuleLabelKeys(); + for (const key of MODULE_KEYS) { + const { category, labelKey } = MODULE_REGISTRY[key]; + const holdsRecords = category !== "export" && category !== "integration"; + expect(keys.includes(labelKey), key).toBe(holdsRecords); + } + }); + + it("lists documents, vaccinations, the cycle and workouts on the page", () => { + const html = render(); + const row = html.match(/data-slot="privacy-stored-modules"[^>]*>([^<]*) MODULE_REGISTRY[key]) + .filter((m) => m.category !== "export" && m.category !== "integration") + .map((m) => m.labelKey); +} + export function PrivacySection() { - const { t } = useTranslations(); + const { t, locale } = useTranslations(); const { isAuthenticated } = useAuth(); const { @@ -91,11 +106,20 @@ export function PrivacySection() { icon={Database} title={t("settings.privacy.stored.title")} /> -
    +
    • {t("settings.privacy.stored.measurements")}
    • -
    • {t("settings.privacy.stored.medications")}
    • -
    • {t("settings.privacy.stored.moodLabs")}
    • -
    • {t("settings.privacy.stored.coach")}
    • +
    • {t("settings.privacy.stored.profile")}
    • +
    • + {t("settings.privacy.stored.modules", { + list: new Intl.ListFormat(locale, { + style: "long", + type: "conjunction", + }).format(storedModuleLabelKeys().map((key) => t(key))), + })} +
    • {t("settings.privacy.stored.integrations")}
    • {t("settings.privacy.stored.security")}
    From a589e15f867ce4999a11838f04d374792a128b29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 20:47:55 +0200 Subject: [PATCH 09/15] Name the arrangement settings hub "Layout" The hub arranges and picks what the dashboard, insights and each module show and holds no theme control, so "Appearance" promised something it does not have. It reads "Layout" in every locale, and its subtitle says what it does. In German "Darstellung" now names only the account page's language-and-units card. --- messages/de.json | 8 ++++---- messages/en.json | 8 ++++---- messages/es.json | 8 ++++---- messages/fr.json | 8 ++++---- messages/it.json | 8 ++++---- messages/ko.json | 4 ++-- messages/pl.json | 8 ++++---- .../settings/__tests__/layout-section.test.tsx | 2 +- .../settings/__tests__/settings-shell.test.tsx | 14 +++++++------- src/components/settings/documents-section.tsx | 2 +- src/components/settings/layout-groups.ts | 4 ++-- src/components/settings/layout-module-gate.tsx | 2 +- src/components/settings/layout-section.tsx | 4 ++-- 13 files changed, 40 insertions(+), 40 deletions(-) diff --git a/messages/de.json b/messages/de.json index 7798c950e..622a39c9b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5331,9 +5331,9 @@ "channelsLink": "Zustellkanäle" }, "layout": { - "title": "Darstellung", - "description": "Lege fest, wie Dashboard, Auswertungen und deine Tracking-Module dargestellt und angeordnet sind.", - "backToHub": "Zurück zu Darstellung", + "title": "Layout", + "description": "Lege fest, was Dashboard, Auswertungen und deine Tracking-Module zeigen und wie sie angeordnet sind.", + "backToHub": "Zurück zu Layout", "dashboard": { "title": "Dashboard", "description": "Kachel-Layout und Reihenfolge." @@ -5342,7 +5342,7 @@ "title": "Insights", "description": "Reihenfolge der Übersicht und der Navigations-Pills." }, - "subtitle": "Lege fest, wie Dashboard, Auswertungen und Tracking-Module dargestellt werden.", + "subtitle": "Lege fest, was Dashboard, Auswertungen und Tracking-Module zeigen und in welcher Reihenfolge.", "medications": { "title": "Medikamente", "description": "Listenansicht und manuelle Reihenfolge." diff --git a/messages/en.json b/messages/en.json index 476b9030e..c80a89a8c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5331,9 +5331,9 @@ "channelsLink": "Delivery channels" }, "layout": { - "title": "Appearance", - "description": "Choose how the dashboard, insights, and your tracking modules look and are arranged.", - "backToHub": "Back to Appearance", + "title": "Layout", + "description": "Choose what the dashboard, insights and your tracking modules show, and how they are arranged.", + "backToHub": "Back to Layout", "dashboard": { "title": "Dashboard", "description": "Tile layout and order." @@ -5342,7 +5342,7 @@ "title": "Insights", "description": "Overview sections and navigation-pill order." }, - "subtitle": "Personalize how your dashboard, insights, and tracking modules look.", + "subtitle": "Choose what your dashboard, insights and tracking modules show, and in which order.", "medications": { "title": "Medications", "description": "List view and manual order." diff --git a/messages/es.json b/messages/es.json index 7bc772cdd..fb6ccf8d4 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5331,9 +5331,9 @@ "channelsLink": "Canales de entrega" }, "layout": { - "title": "Apariencia", - "description": "Elige cómo se ven y se organizan el panel, los análisis y tus módulos de seguimiento.", - "backToHub": "Volver a Apariencia", + "title": "Diseño", + "description": "Elige qué muestran el panel, los análisis y tus módulos de seguimiento, y cómo se organizan.", + "backToHub": "Volver a Diseño", "dashboard": { "title": "Panel", "description": "Diseño y orden de los mosaicos." @@ -5342,7 +5342,7 @@ "title": "Análisis", "description": "Orden de la vista general y de las pestañas de navegación." }, - "subtitle": "Personaliza cómo se ven tu panel, los análisis y los módulos de seguimiento.", + "subtitle": "Elige qué muestran tu panel, los análisis y los módulos de seguimiento, y en qué orden.", "medications": { "title": "Medicamentos", "description": "Vista de lista y orden manual." diff --git a/messages/fr.json b/messages/fr.json index d13999e72..57f04f403 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5331,9 +5331,9 @@ "channelsLink": "Canaux de diffusion" }, "layout": { - "title": "Apparence", - "description": "Choisissez l'apparence et l'agencement du tableau de bord, des analyses et de vos modules de suivi.", - "backToHub": "Retour à Apparence", + "title": "Disposition", + "description": "Choisissez ce qu'affichent le tableau de bord, les analyses et vos modules de suivi, et leur agencement.", + "backToHub": "Retour à Disposition", "dashboard": { "title": "Tableau de bord", "description": "Disposition et ordre des tuiles." @@ -5342,7 +5342,7 @@ "title": "Analyses", "description": "Ordre de la vue d'ensemble et des onglets de navigation." }, - "subtitle": "Personnalisez l'apparence du tableau de bord, des analyses et des modules de suivi.", + "subtitle": "Choisissez ce qu'affichent le tableau de bord, les analyses et les modules de suivi, et dans quel ordre.", "medications": { "title": "Médicaments", "description": "Vue en liste et ordre manuel." diff --git a/messages/it.json b/messages/it.json index e2d3097cb..3ba54418d 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5331,9 +5331,9 @@ "channelsLink": "Canali di recapito" }, "layout": { - "title": "Aspetto", - "description": "Scegli l'aspetto e la disposizione di dashboard, approfondimenti e dei tuoi moduli di monitoraggio.", - "backToHub": "Torna ad Aspetto", + "title": "Layout", + "description": "Scegli cosa mostrano dashboard, approfondimenti e i tuoi moduli di monitoraggio, e come sono disposti.", + "backToHub": "Torna a Layout", "dashboard": { "title": "Cruscotto", "description": "Layout e ordine dei riquadri." @@ -5342,7 +5342,7 @@ "title": "Approfondimenti", "description": "Ordine della panoramica e delle schede di navigazione." }, - "subtitle": "Personalizza l'aspetto di dashboard, approfondimenti e moduli di monitoraggio.", + "subtitle": "Scegli cosa mostrano dashboard, approfondimenti e moduli di monitoraggio, e in quale ordine.", "medications": { "title": "Farmaci", "description": "Vista elenco e ordine manuale." diff --git a/messages/ko.json b/messages/ko.json index 933ed797e..3ce0d4d83 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -5332,7 +5332,7 @@ }, "layout": { "title": "화면 구성", - "description": "대시보드, 인사이트, 기록 모듈의 모습과 배치를 정해요.", + "description": "대시보드, 인사이트, 기록 모듈에 무엇을 보여주고 어떻게 배치할지 정해요.", "backToHub": "화면 구성으로 돌아가기", "dashboard": { "title": "대시보드", @@ -5342,7 +5342,7 @@ "title": "인사이트", "description": "개요 섹션과 내비게이션 칩 순서." }, - "subtitle": "대시보드, 인사이트, 기록 모듈의 모습을 원하는 대로 바꿔요.", + "subtitle": "대시보드, 인사이트, 기록 모듈에 무엇을 어떤 순서로 보여줄지 정해요.", "medications": { "title": "복약", "description": "목록 보기와 수동 순서." diff --git a/messages/pl.json b/messages/pl.json index 733555615..1e9e4bffd 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5331,9 +5331,9 @@ "channelsLink": "Kanały dostarczania" }, "layout": { - "title": "Wygląd", - "description": "Ustal, jak wyglądają i są rozłożone pulpit, analizy i Twoje moduły śledzenia.", - "backToHub": "Powrót do Wygląd", + "title": "Układ", + "description": "Ustal, co pokazują pulpit, analizy i Twoje moduły śledzenia oraz jak są rozłożone.", + "backToHub": "Powrót do układu", "dashboard": { "title": "Pulpit", "description": "Układ i kolejność kafelków." @@ -5342,7 +5342,7 @@ "title": "Analizy", "description": "Kolejność przeglądu i pigułek nawigacyjnych." }, - "subtitle": "Dostosuj wygląd pulpitu, analiz i modułów śledzenia.", + "subtitle": "Wybierz, co pokazują pulpit, analizy i moduły śledzenia oraz w jakiej kolejności.", "medications": { "title": "Leki", "description": "Widok listy i ręczna kolejność." diff --git a/src/components/settings/__tests__/layout-section.test.tsx b/src/components/settings/__tests__/layout-section.test.tsx index 5b232197d..68264986a 100644 --- a/src/components/settings/__tests__/layout-section.test.tsx +++ b/src/components/settings/__tests__/layout-section.test.tsx @@ -1,5 +1,5 @@ /** - * v1.25.11 (#148) — the "Appearance" hub (slug stays `layout`). + * v1.25.11 (#148) — the "Layout" hub (slug stays `layout`). * * The hub is a navigable index: it lists each module as a clickable row * (`` with the localized title + description + diff --git a/src/components/settings/__tests__/settings-shell.test.tsx b/src/components/settings/__tests__/settings-shell.test.tsx index fc16d5808..9f0590c19 100644 --- a/src/components/settings/__tests__/settings-shell.test.tsx +++ b/src/components/settings/__tests__/settings-shell.test.tsx @@ -433,11 +433,11 @@ describe("", () => { // `/notifications` inbox now shares the "Notifications" label. expect(html).toContain("Notifications"); // v1.17.1 (F-2) — the view/arrangement editors are reached through one hub - // entry (route stays `/settings/layout`). v1.25.3 — the hub is renamed - // "Appearance" (DE "Darstellung") and indexes every module's view surface; - // the route is unchanged. + // entry (route stays `/settings/layout`). v1.42 — the hub is named + // "Layout": it arranges and picks what each module shows and holds no + // theme, so "Appearance" promised a control it does not have. expect(html).toContain('href="/settings/layout"'); - expect(html).toContain(">Appearance"); + expect(html).toContain(">Layout"); // v1.25.3 — Channels folded into Notifications, so it is no longer a // left-side entry; Sources keeps its own. expect(html).not.toContain('href="/settings/channels"'); @@ -478,10 +478,10 @@ describe("", () => { // inbox now shares the "Benachrichtigungen" label. expect(html).toContain("Benachrichtigungen"); // v1.17.1 (F-2) — the view editors are reached through one hub entry - // (route stays `/settings/layout`). v1.25.3 — the German nav label reads - // "Darstellung" (Appearance) to match the renamed hub. + // (route stays `/settings/layout`). v1.42 — "Layout" in German too; + // "Darstellung" now names the account page's language-and-units card. expect(html).toContain('href="/settings/layout"'); - expect(html).toContain(">Darstellung"); + expect(html).toContain(">Layout"); // v1.25.3 — Channels ("Kanäle") folded into Notifications; Sources keeps // its own left-side entry. expect(html).not.toContain('href="/settings/channels"'); diff --git a/src/components/settings/documents-section.tsx b/src/components/settings/documents-section.tsx index 35f60b49b..c5539c8db 100644 --- a/src/components/settings/documents-section.tsx +++ b/src/components/settings/documents-section.tsx @@ -1,7 +1,7 @@ "use client"; /** - * Settings → Appearance → Documents. + * Settings → Layout → Documents. * * Reached from the wrench beside the vault's Upload button, the way the * illness journal's wrench reaches its own page. Two choices, each in its own diff --git a/src/components/settings/layout-groups.ts b/src/components/settings/layout-groups.ts index 5186a54bb..07e7de564 100644 --- a/src/components/settings/layout-groups.ts +++ b/src/components/settings/layout-groups.ts @@ -12,7 +12,7 @@ import { DocumentsSection } from "./documents-section"; import { VorsorgeSection } from "./vorsorge-section"; /** - * v1.25.11 (#148) — single source of truth for the "Appearance" (slug + * v1.25.11 (#148) — single source of truth for the "Layout" (slug * `layout`) hub + its per-module subpages. * * "How my app looks and is arranged" was scattered across several unrelated @@ -21,7 +21,7 @@ import { VorsorgeSection } from "./vorsorge-section"; * back into a HUB → SUBPAGE model: `/settings/layout` lists the modules as * clickable rows, and each row opens `/settings/layout/`, which renders * ONLY that module's section (the same existing section component, verbatim) - * with a "← Appearance" back-link. + * with a "← Layout" back-link. * * This module is intentionally NOT `"use client"`: the client hub * (`layout-section.tsx`) AND the server subpage route diff --git a/src/components/settings/layout-module-gate.tsx b/src/components/settings/layout-module-gate.tsx index 1e5bb3465..e3e56c13e 100644 --- a/src/components/settings/layout-module-gate.tsx +++ b/src/components/settings/layout-module-gate.tsx @@ -11,7 +11,7 @@ import { useTranslations } from "@/lib/i18n/context"; import type { ModuleKey } from "@/lib/modules/registry"; /** - * Client gate for an Appearance subpage (`/settings/layout/`). + * Client gate for a Layout subpage (`/settings/layout/`). * * The subpage is a real URL a person can land on (a bookmark, the hub's own * link, a link somebody sent) with its module switched off. It answers in diff --git a/src/components/settings/layout-section.tsx b/src/components/settings/layout-section.tsx index 78b3832b3..d870b7b5a 100644 --- a/src/components/settings/layout-section.tsx +++ b/src/components/settings/layout-section.tsx @@ -11,14 +11,14 @@ import { isSurfaceVisible } from "@/lib/modules/surface"; import { LAYOUT_GROUPS } from "./layout-groups"; /** - * v1.25.11 (#148) — the "Appearance" hub (slug stays `layout`). + * v1.25.11 (#148) — the "Layout" hub (slug stays `layout`). * * The hub is a navigable index, NOT a stacked composition: it lists each * module as a clickable row (title + description + chevron) that opens the * module's own subpage at `/settings/layout/`. Nothing is stacked inline; * the per-module view/sort surfaces live on their subpages so the hub reads as * a short, scannable directory. The visible page heading + subtitle - * ("Appearance" / "Personalize how …") are painted by `SettingsShell` from the + * ("Layout" / "Choose what …") are painted by `SettingsShell` from the * `layout` slug; this body renders only the row list. * * Per-module gating fails OPEN (`!== false`): a missing key reads as enabled so From 0fd1168fd9cf7c51cca812aa76346650b2281967 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 20:53:17 +0200 Subject: [PATCH 10/15] Pin the connector settings at one fewer success tint The endpoint card's green Enabled badge went with the status badge that repeated its switch. --- src/__tests__/success-affordance-guard.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts index b1333d4b0..dffde273e 100644 --- a/src/__tests__/success-affordance-guard.test.ts +++ b/src/__tests__/success-affordance-guard.test.ts @@ -379,7 +379,7 @@ const PINNED_AFFORDANCES: Record< "text-success": 2, }, "src/components/settings/mcp-section.tsx": { - "text-success": 5, + "text-success": 4, "toast.success": 2, }, "src/components/settings/modules-section.tsx": { "toast.success": 1 }, From 8ff35231f8e3361bda8ef0ef2a03bfc27ad84580 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 20:53:18 +0200 Subject: [PATCH 11/15] Write English navigation and insights chip labels in sentence case Settings, admin and main navigation read "Data & privacy", "Export & import", "Health score", "API & tokens", "MCP connector", "System status", "Danger zone" and "Admin console", and the insights metric chips drop their title case ("Blood pressure", "Body fat" and the rest), matching the labels around them. The other locales already followed their own conventions; the API page title is translated where it had been left in English. --- messages/en.json | 74 +++++++++---------- messages/es.json | 2 +- messages/fr.json | 2 +- messages/it.json | 2 +- messages/pl.json | 2 +- .../__tests__/insights-tab-strip.test.tsx | 8 +- .../__tests__/export-section.test.tsx | 2 +- .../settings/__tests__/sections.test.tsx | 2 +- .../__tests__/settings-shell.test.tsx | 4 +- 9 files changed, 49 insertions(+), 49 deletions(-) diff --git a/messages/en.json b/messages/en.json index c80a89a8c..ff7056f82 100644 --- a/messages/en.json +++ b/messages/en.json @@ -392,7 +392,7 @@ "insights": "Insights", "achievements": "Achievements", "settings": "Settings", - "admin": "Admin Console", + "admin": "Admin console", "logout": "Sign out", "login": "Sign in", "notifications": "Notifications", @@ -3279,7 +3279,7 @@ "relativeDaysAgoFew": "{count} days ago", "navOverview": "Overview", "navCatalog": "All metrics", - "navBloodPressure": "Blood Pressure", + "navBloodPressure": "Blood pressure", "navWeight": "Weight", "navPulse": "Pulse", "navMood": "Mood", @@ -3814,7 +3814,7 @@ "description": "Body-water readings appear here once a connected body-composition scale syncs." } }, - "navTotalBodyWater": "Body Water", + "navTotalBodyWater": "Body water", "boneMass": { "title": "Bone Mass", "description": "Your bone mass over time, in kilograms.", @@ -3824,7 +3824,7 @@ "description": "Bone-mass readings appear here once a connected body-composition scale syncs." } }, - "navBoneMass": "Bone Mass", + "navBoneMass": "Bone mass", "flightsClimbed": { "title": "Flights Climbed", "description": "Flights of stairs you climbed each day.", @@ -3854,7 +3854,7 @@ "description": "Fat-free-mass readings appear here once a connected body-composition scale syncs." } }, - "navFatFreeMass": "Fat-Free Mass", + "navFatFreeMass": "Fat-free mass", "fatMass": { "title": "Fat Mass", "description": "Your fat mass over time, in kilograms.", @@ -3874,8 +3874,8 @@ "cta": "Log body fat" } }, - "navFatMass": "Fat Mass", - "navBodyFat": "Body Fat", + "navFatMass": "Fat mass", + "navBodyFat": "Body fat", "muscleMass": { "title": "Muscle Mass", "description": "Your muscle mass over time, in kilograms.", @@ -3885,7 +3885,7 @@ "description": "Muscle-mass readings appear here once a connected body-composition scale syncs." } }, - "navMuscleMass": "Muscle Mass", + "navMuscleMass": "Muscle mass", "skinTemperature": { "title": "Skin Temperature", "description": "Your wrist skin temperature over time, in degrees Celsius.", @@ -3895,7 +3895,7 @@ "description": "Skin-temperature readings appear here once a wearable syncs overnight wrist temperature." } }, - "navSkinTemperature": "Skin Temp", + "navSkinTemperature": "Skin temp", "pulseWaveVelocity": { "title": "Pulse Wave Velocity", "description": "Your arterial pulse-wave velocity over time, in m/s — higher values suggest stiffer arteries.", @@ -3905,7 +3905,7 @@ "description": "Pulse-wave-velocity readings appear here once a compatible Withings device syncs." } }, - "navPulseWaveVelocity": "Pulse Wave Velocity", + "navPulseWaveVelocity": "Pulse wave velocity", "vascularAge": { "title": "Vascular Age", "description": "Your estimated vascular age over time, in years.", @@ -3915,7 +3915,7 @@ "description": "Vascular-age estimates appear here once a compatible Withings device syncs." } }, - "navVascularAge": "Vascular Age", + "navVascularAge": "Vascular age", "visceralFat": { "title": "Visceral Fat", "description": "Your visceral-fat rating over time.", @@ -3925,7 +3925,7 @@ "description": "Visceral-fat ratings appear here once a compatible body-composition scale syncs." } }, - "navVisceralFat": "Visceral Fat", + "navVisceralFat": "Visceral fat", "audioExposureEnv": { "title": "Environmental Audio", "description": "Your daily average environmental sound exposure, in dBA.", @@ -3935,7 +3935,7 @@ "description": "Environmental sound-level readings appear here once Apple Health syncs." } }, - "navAudioExposureEnv": "Environmental Audio", + "navAudioExposureEnv": "Environmental audio", "audioExposureHeadphone": { "title": "Headphone Audio", "description": "Your daily average headphone audio exposure, in dBA.", @@ -3945,7 +3945,7 @@ "description": "Headphone audio-level readings appear here once Apple Health syncs." } }, - "navAudioExposureHeadphone": "Headphone Audio", + "navAudioExposureHeadphone": "Headphone audio", "timeInDaylight": { "title": "Time in Daylight", "description": "Minutes spent in daylight each day.", @@ -3965,7 +3965,7 @@ "description": "Walking-steadiness readings appear here once Apple Health syncs." } }, - "navWalkingSteadiness": "Walking Steadiness", + "navWalkingSteadiness": "Walking steadiness", "audioExposureEvent": { "title": "Loud Audio Events", "description": "Days with a loud-environment exposure event.", @@ -3975,7 +3975,7 @@ "description": "Loud-environment events appear here once Apple Health syncs." } }, - "navAudioExposureEvent": "Loud Events", + "navAudioExposureEvent": "Loud events", "respiratoryRate": { "title": "Respiratory Rate", "description": "Your respiratory rate over time, in breaths per minute.", @@ -3985,7 +3985,7 @@ "description": "Respiratory-rate readings appear here once Apple Health syncs sleep or workout breathing." } }, - "navRespiratoryRate": "Respiratory Rate", + "navRespiratoryRate": "Respiratory rate", "leanBodyMass": { "title": "Lean Body Mass", "description": "Your lean body mass over time, in kilograms.", @@ -3995,7 +3995,7 @@ "description": "Lean-body-mass readings appear here once a connected body-composition scale syncs." } }, - "navLeanBodyMass": "Lean Body Mass", + "navLeanBodyMass": "Lean body mass", "walkingHeartRateAverage": { "title": "Walking Heart Rate", "description": "Your average heart rate while walking, in bpm.", @@ -4005,7 +4005,7 @@ "description": "Walking-heart-rate readings appear here once Apple Health syncs." } }, - "navWalkingHeartRateAverage": "Walking Heart Rate", + "navWalkingHeartRateAverage": "Walking heart rate", "walkingAsymmetry": { "title": "Walking Asymmetry", "description": "Your walking asymmetry over time, as a percentage.", @@ -4015,7 +4015,7 @@ "description": "Walking-asymmetry readings appear here once Apple Health syncs." } }, - "navWalkingAsymmetry": "Walking Asymmetry", + "navWalkingAsymmetry": "Walking asymmetry", "walkingDoubleSupport": { "title": "Double Support Time", "description": "The percentage of each stride spent with both feet on the ground.", @@ -4025,7 +4025,7 @@ "description": "Double-support readings appear here once Apple Health syncs." } }, - "navWalkingDoubleSupport": "Double Support", + "navWalkingDoubleSupport": "Double support", "walkingStepLength": { "title": "Walking Step Length", "description": "Your average walking step length over time, in metres.", @@ -4035,7 +4035,7 @@ "description": "Step-length readings appear here once Apple Health syncs." } }, - "navWalkingStepLength": "Step Length", + "navWalkingStepLength": "Step length", "walkingSpeed": { "title": "Walking Speed", "description": "Your average walking speed over time, in km/h.", @@ -4045,7 +4045,7 @@ "description": "Walking-speed readings appear here once Apple Health syncs." } }, - "navWalkingSpeed": "Walking Speed", + "navWalkingSpeed": "Walking speed", "mood": { "logLink": "Log a mood entry", "heatmapTitle": "Mood calendar", @@ -4426,7 +4426,7 @@ "description": "VO₂ max readings appear here once Apple Health syncs from your workouts." } }, - "navCardioFitness": "Cardio Fitness", + "navCardioFitness": "Cardio fitness", "cardioRecovery": { "title": "Cardio Recovery", "description": "How far your heart rate drops one minute after peak exercise.", @@ -4436,7 +4436,7 @@ "description": "Cardio-recovery readings appear here once Apple Health syncs from a workout." } }, - "navCardioRecovery": "Cardio Recovery", + "navCardioRecovery": "Cardio recovery", "wristTemperature": { "title": "Wrist Temperature", "description": "Your overnight wrist temperature, tracked against your own baseline.", @@ -4446,7 +4446,7 @@ "description": "Wrist-temperature readings appear here once Apple Health syncs overnight wear." } }, - "navWristTemperature": "Wrist Temp", + "navWristTemperature": "Wrist temp", "falls": { "title": "Falls", "description": "Hard falls your Apple Watch detected, counted per day.", @@ -4466,7 +4466,7 @@ "description": "Six-minute-walk estimates appear here once Apple Health syncs." } }, - "navSixMinuteWalk": "6-Min Walk", + "navSixMinuteWalk": "6-min walk", "stairAscentSpeed": { "title": "Stair Ascent Speed", "description": "How fast you climb stairs, measured by your iPhone or Watch.", @@ -4476,7 +4476,7 @@ "description": "Stair-ascent-speed readings appear here once Apple Health syncs." } }, - "navStairAscentSpeed": "Stair Ascent", + "navStairAscentSpeed": "Stair ascent", "stairDescentSpeed": { "title": "Stair Descent Speed", "description": "How fast you go down stairs, measured by your iPhone or Watch.", @@ -4486,7 +4486,7 @@ "description": "Stair-descent-speed readings appear here once Apple Health syncs." } }, - "navStairDescentSpeed": "Stair Descent", + "navStairDescentSpeed": "Stair descent", "breathingDisturbances": { "title": "Breathing Disturbances", "description": "How often your breathing was disturbed during sleep.", @@ -5469,7 +5469,7 @@ "subtitle": "Choose your AI provider and bring your own key." }, "api": { - "title": "API & Tokens", + "title": "API & tokens", "description": "Bearer tokens for your own scripts and apps — log measurements or medication intake from anywhere.", "subtitle": "Manage API endpoints and access tokens." }, @@ -5479,7 +5479,7 @@ "subtitle": "Data reset and account deletion." }, "export": { - "title": "Export & Import", + "title": "Export & import", "description": "Download your data as CSV or a full JSON backup — or import from Apple Health, JSON, or CSV. The doctor report now lives under Health Record.", "otherOptionsHeading": "Other export options", "downloadFailed": "Couldn't download the export. Please try again.", @@ -5778,12 +5778,12 @@ "subtitle": "Two-factor authentication, recovery codes, and passkeys." }, "privacy": { - "title": "Data & Privacy", + "title": "Data & privacy", "subtitle": "Your data, retention, encryption, and account security in one place.", "description": "Export, deletion, retention, encryption status, sessions, and security activity." }, "mcp": { - "title": "MCP Connector", + "title": "MCP connector", "subtitle": "Connect an external assistant to your own health data." }, "anamnesis": { @@ -5848,7 +5848,7 @@ } }, "score": { - "title": "Health Score", + "title": "Health score", "subtitle": "Choose which pillars count toward your score.", "card": { "title": "What counts toward your score", @@ -7564,7 +7564,7 @@ }, "section": { "system-status": { - "title": "System Status", + "title": "System status", "subtitle": "Worker, database, and integration health at a glance." }, "general": { @@ -7610,7 +7610,7 @@ "subtitle": "Mint, track, and revoke registration invite links." }, "api-tokens": { - "title": "API Tokens", + "title": "API tokens", "subtitle": "Active bearer tokens issued to native clients and integrations." }, "login-overview": { @@ -7830,7 +7830,7 @@ "restoreSkippedVaccinationCiphertext": "Vaccination note sealed with a key this server does not hold (dose kept without it)" }, "danger-zone": { - "title": "Danger Zone", + "title": "Danger zone", "subtitle": "Destructive system-wide operations." }, "about": { diff --git a/messages/es.json b/messages/es.json index fb6ccf8d4..69d433d5b 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5469,7 +5469,7 @@ "subtitle": "Elige tu proveedor de IA y usa tu propia clave." }, "api": { - "title": "API & Tokens", + "title": "API y tokens", "description": "Tokens Bearer para tus propios scripts y apps — registra mediciones o tomas de medicación desde cualquier lugar.", "subtitle": "Gestiona los endpoints de la API y los tokens de acceso." }, diff --git a/messages/fr.json b/messages/fr.json index 57f04f403..a21579d0d 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5469,7 +5469,7 @@ "subtitle": "Choisissez votre fournisseur d'IA et utilisez votre propre clé." }, "api": { - "title": "API & Tokens", + "title": "API et jetons", "description": "Jetons Bearer pour vos propres scripts et applis — enregistrez des mesures ou des prises de médicaments depuis n’importe où.", "subtitle": "Gérez les points d'accès API et les jetons d'accès." }, diff --git a/messages/it.json b/messages/it.json index 3ba54418d..b901aa65a 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5469,7 +5469,7 @@ "subtitle": "Scegli il tuo provider IA e usa la tua chiave." }, "api": { - "title": "API & Tokens", + "title": "API e token", "description": "Token Bearer per i tuoi script e le tue app — registra misurazioni o assunzioni di farmaci da ovunque.", "subtitle": "Gestisci gli endpoint API e i token di accesso." }, diff --git a/messages/pl.json b/messages/pl.json index 1e9e4bffd..e67215a2f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5469,7 +5469,7 @@ "subtitle": "Wybierz dostawcę AI i użyj własnego klucza." }, "api": { - "title": "API & Tokens", + "title": "API i tokeny", "description": "Tokeny Bearer dla własnych skryptów i aplikacji — zapisuj pomiary lub przyjęcia leków z dowolnego miejsca.", "subtitle": "Zarządzaj punktami końcowymi API i tokenami dostępu." }, diff --git a/src/components/insights/__tests__/insights-tab-strip.test.tsx b/src/components/insights/__tests__/insights-tab-strip.test.tsx index fed757976..8acb91e5a 100644 --- a/src/components/insights/__tests__/insights-tab-strip.test.tsx +++ b/src/components/insights/__tests__/insights-tab-strip.test.tsx @@ -50,7 +50,7 @@ describe(" — availability gating (v1.4.27 F19)", () => { // only Overview, then pills light up as data arrives. const html = render(); expect(html).toContain(">Overview<"); - expect(html).not.toContain(">Blood Pressure<"); + expect(html).not.toContain(">Blood pressure<"); expect(html).not.toContain(">Weight<"); expect(html).not.toContain(">Pulse<"); expect(html).not.toContain(">Mood<"); @@ -75,7 +75,7 @@ describe(" — availability gating (v1.4.27 F19)", () => { expect(html).toContain(">Weight<"); expect(html).toContain('data-group="body"'); // Pills without data drop. - expect(html).not.toContain(">Blood Pressure<"); + expect(html).not.toContain(">Blood pressure<"); expect(html).not.toContain(">Mood<"); expect(html).not.toContain(">Medication<"); expect(html).not.toContain(">Sleep<"); @@ -234,7 +234,7 @@ describe(" — vitals group collapse (v1.4.34 IW-D)", () => { it("preserves the pinned flat pills inline", () => { const html = render(); // Pinned pills must still be reachable directly. - expect(html).toContain(">Blood Pressure<"); + expect(html).toContain(">Blood pressure<"); expect(html).toContain(">Weight<"); expect(html).toContain(">Sleep<"); expect(html).toContain(">Mood<"); @@ -361,7 +361,7 @@ describe(" — saved-layout visibility gate (v1.15.14 W2)", () visibleTileIds={visibleTileIds} />, ); - expect(html).not.toContain(">Blood Pressure<"); + expect(html).not.toContain(">Blood pressure<"); expect(html).toContain(">Weight<"); }); diff --git a/src/components/settings/__tests__/export-section.test.tsx b/src/components/settings/__tests__/export-section.test.tsx index fc42f9c5a..aaf2fb52b 100644 --- a/src/components/settings/__tests__/export-section.test.tsx +++ b/src/components/settings/__tests__/export-section.test.tsx @@ -63,7 +63,7 @@ describe(" — SSR smoke", () => { , ); // v1.15.7 — the section was relabelled "Export & Import" (issue #281). - expect(html).toContain("Export & Import"); + expect(html).toContain("Export & import"); // Raw key never leaks past i18n. expect(html).not.toContain("settings.sections.export."); }); diff --git a/src/components/settings/__tests__/sections.test.tsx b/src/components/settings/__tests__/sections.test.tsx index 701c95fc6..ce6e1f557 100644 --- a/src/components/settings/__tests__/sections.test.tsx +++ b/src/components/settings/__tests__/sections.test.tsx @@ -258,7 +258,7 @@ describe("settings sections — SSR smoke", () => { it(" renders endpoints + tokens cards", () => { const html = renderFramed("api", ); - expect(html).toContain("API & Tokens"); + expect(html).toContain("API & tokens"); }); it(" renders the danger-zone card only (export moved to /settings/export)", () => { diff --git a/src/components/settings/__tests__/settings-shell.test.tsx b/src/components/settings/__tests__/settings-shell.test.tsx index 9f0590c19..82a9db23a 100644 --- a/src/components/settings/__tests__/settings-shell.test.tsx +++ b/src/components/settings/__tests__/settings-shell.test.tsx @@ -451,7 +451,7 @@ describe("", () => { expect(html).not.toContain('href="/settings/sharing"'); // The ampersand is HTML-escaped by React SSR — assert on the encoded // form so we don't accidentally match a parser that double-escapes. - expect(html).toContain("API & Tokens"); + expect(html).toContain("API & tokens"); // v1.18.0 (S5) — the health record is its own top-level entry. expect(html).toContain('href="/settings/gesundheitsakte"'); expect(html).toContain("Health record"); @@ -494,7 +494,7 @@ describe("", () => { // v1.18.6 (W9) — the AI section is named "KI-Anbieter" in German: the // page is about the provider / BYOK, not an "Auswertung". expect(html).toContain("KI-Anbieter"); - // API & Tokens is identical in both locales (proper noun + ampersand) + // German keeps the noun capitalised: "API & Tokens". expect(html).toContain("API & Tokens"); expect(html).toContain("Erweitert"); // "Über diese App" (About) is back as the last in-shell nav entry From d99306275c124a7038f3a673d46e763787fd4c8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 20:55:01 +0200 Subject: [PATCH 12/15] Drop the settings section descriptions nothing reads Sixteen settings.sections..description strings per locale had no call site: the shell paints each section's subtitle, and no literal or key template builds a section description. The reverse-coverage guard counted them as reached only because its suffix arm accepts any `${prefix}.description` template next to a sibling key such as .title. --- messages/de.json | 20 ++------------------ messages/en.json | 20 ++------------------ messages/es.json | 20 ++------------------ messages/fr.json | 20 ++------------------ messages/it.json | 20 ++------------------ messages/ko.json | 20 ++------------------ messages/pl.json | 20 ++------------------ 7 files changed, 14 insertions(+), 126 deletions(-) diff --git a/messages/de.json b/messages/de.json index 622a39c9b..445c875ee 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5310,12 +5310,10 @@ "sections": { "account": { "title": "Konto", - "description": "Profil, Passwort, Passkeys und deine Onboarding-Tour.", "subtitle": "Dein Profil, deine Identität und deine Anmeldedaten." }, "integrations": { "title": "Integrationen", - "description": "Verbundene Dienste, Zustellkanäle und die Quellen-Priorität an einem Ort.", "subtitle": "Datenquellen und Geräte verbinden und verwalten.", "connectionsHeading": "Verbindungen", "channelsHeading": "Zustellkanäle", @@ -5324,7 +5322,6 @@ }, "notifications": { "title": "Benachrichtigungen", - "description": "Alle Erinnerungen an einem Ort — was wann erinnert wird und wohin es zugestellt wird.", "subtitle": "Wähle, welche Erinnerungen du erhältst und wohin sie zugestellt werden.", "remindersHeading": "Erinnerungen", "matrixLink": "Benachrichtigungen je Kanal", @@ -5332,7 +5329,6 @@ }, "layout": { "title": "Layout", - "description": "Lege fest, was Dashboard, Auswertungen und deine Tracking-Module zeigen und wie sie angeordnet sind.", "backToHub": "Zurück zu Layout", "dashboard": { "title": "Dashboard", @@ -5373,12 +5369,10 @@ }, "thresholds": { "title": "Zielwerte", - "description": "Pro Metrik dein eigener Zielbereich für gesunde Werte.", "subtitle": "Lege deine persönlichen Zielbereiche je Messgröße fest." }, "sources": { "title": "Quellen-Priorität", - "description": "Lege fest, welche Quelle führt, wenn mehrere am selben Tag dieselbe Metrik liefern.", "integrationsHint": "Neue Datenquellen verbindest oder trennst du auf der Integrationen-Seite.", "integrationsHintLink": "Integrationen öffnen", "cardTitle": "Rangfolge je Messgröße", @@ -5465,22 +5459,18 @@ }, "ai": { "title": "KI-Anbieter", - "description": "Provider, Modell und API-Key für die Auswertungen — dazu dein „Über mich“-Kontext.", "subtitle": "Wähle deinen KI-Anbieter und hinterlege deinen eigenen Schlüssel." }, "api": { "title": "API & Tokens", - "description": "Bearer-Tokens für eigene Skripte und Apps — erfasse Messwerte oder Einnahmen von überall.", "subtitle": "API-Endpunkte und Zugriffstoken verwalten." }, "advanced": { "title": "Erweitert", - "description": "Der Notausgang: alle Gesundheitsdaten unwiderruflich löschen oder das Konto auflösen.", "subtitle": "Daten zurücksetzen und Konto löschen." }, "export": { "title": "Export & Import", - "description": "Lade deine Daten als CSV oder vollständiges JSON-Backup herunter — oder importiere aus Apple Health, JSON oder CSV. Den Arztbericht findest du jetzt unter Gesundheitsakte.", "otherOptionsHeading": "Weitere Export-Optionen", "downloadFailed": "Der Export konnte nicht heruntergeladen werden. Bitte versuche es erneut.", "actions": { @@ -5725,12 +5715,10 @@ }, "about": { "title": "Über diese App", - "description": "Version, Lizenz, Links.", "subtitle": "Version, Lizenz und Projektlinks." }, "sharing": { "title": "Freigabe", - "description": "Erstelle einen schreibgeschützten Link, um deine Gesundheitsakte mit einer Ärztin oder einem Arzt zu teilen.", "subtitle": "Teile schreibgeschützten Zugriff auf deine Daten." }, "insights": { @@ -5765,12 +5753,10 @@ }, "gesundheitsakte": { "title": "Gesundheitsakte", - "description": "Vollständige Gesundheitsakte als PDF, FHIR R4 oder kombiniertes ZIP-Paket exportieren.", "subtitle": "Exportiere eine vollständige, arzttaugliche Gesundheitsakte." }, "coach": { "title": "Coach", - "description": "Coach aktivieren, Ton und Umfang, gespeicherte Erinnerungen.", "subtitle": "Stelle den Coach, seine proaktiven Hinweise und sein Gedächtnis ein." }, "security": { @@ -5779,8 +5765,7 @@ }, "privacy": { "title": "Daten & Datenschutz", - "subtitle": "Deine Daten, Aufbewahrung, Verschlüsselung und Kontosicherheit an einem Ort.", - "description": "Export, Löschung, Aufbewahrung, Verschlüsselungsstatus, Sitzungen und Sicherheitsaktivität." + "subtitle": "Deine Daten, Aufbewahrung, Verschlüsselung und Kontosicherheit an einem Ort." }, "mcp": { "title": "MCP-Connector", @@ -5788,8 +5773,7 @@ }, "anamnesis": { "title": "Anamnese", - "subtitle": "Deine Vorgeschichte, Lebensumstände, Notfalldaten und was die KI lesen darf.", - "description": "Strukturierte Datensätze zu Allergien und Familienanamnese." + "subtitle": "Deine Vorgeschichte, Lebensumstände, Notfalldaten und was die KI lesen darf." }, "environment": { "title": "Standort & Wetter", diff --git a/messages/en.json b/messages/en.json index ff7056f82..c474dd409 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5310,12 +5310,10 @@ "sections": { "account": { "title": "Account", - "description": "Profile, password, passkeys, and your onboarding tour.", "subtitle": "Your profile, identity, and sign-in details." }, "integrations": { "title": "Integrations", - "description": "Connected services, delivery channels, and source priority in one place.", "subtitle": "Connect and manage your data sources and devices.", "connectionsHeading": "Connections", "channelsHeading": "Delivery channels", @@ -5324,7 +5322,6 @@ }, "notifications": { "title": "Notifications", - "description": "Every reminder in one place — what gets reminded and when, and where it's delivered.", "subtitle": "Choose which reminders you receive and where they're delivered.", "remindersHeading": "Reminders", "matrixLink": "Per-channel notifications", @@ -5332,7 +5329,6 @@ }, "layout": { "title": "Layout", - "description": "Choose what the dashboard, insights and your tracking modules show, and how they are arranged.", "backToHub": "Back to Layout", "dashboard": { "title": "Dashboard", @@ -5373,12 +5369,10 @@ }, "thresholds": { "title": "Targets", - "description": "Per metric: your own target range for healthy values.", "subtitle": "Set your personal target ranges per metric." }, "sources": { "title": "Source priority", - "description": "Pick which ingest source wins when more than one records the same metric for the same day.", "integrationsHint": "Connect or disconnect data sources on the Integrations page.", "integrationsHintLink": "Open integrations", "cardTitle": "Ranking per metric", @@ -5465,22 +5459,18 @@ }, "ai": { "title": "AI provider", - "description": "Provider, model, and API key for the insights — plus your “about me” context.", "subtitle": "Choose your AI provider and bring your own key." }, "api": { "title": "API & tokens", - "description": "Bearer tokens for your own scripts and apps — log measurements or medication intake from anywhere.", "subtitle": "Manage API endpoints and access tokens." }, "advanced": { "title": "Advanced", - "description": "The way out: erase every health record for good, or close the account entirely.", "subtitle": "Data reset and account deletion." }, "export": { "title": "Export & import", - "description": "Download your data as CSV or a full JSON backup — or import from Apple Health, JSON, or CSV. The doctor report now lives under Health Record.", "otherOptionsHeading": "Other export options", "downloadFailed": "Couldn't download the export. Please try again.", "actions": { @@ -5725,12 +5715,10 @@ }, "about": { "title": "About this app", - "description": "Version, license, links.", "subtitle": "Version, license, and project links." }, "sharing": { "title": "Sharing", - "description": "Create a read-only link to share your health record with a clinician.", "subtitle": "Share read-only access to your data." }, "insights": { @@ -5765,12 +5753,10 @@ }, "gesundheitsakte": { "title": "Health record", - "description": "Export your full health record as PDF, FHIR R4, or a combined ZIP package.", "subtitle": "Export a complete, clinician-ready health record." }, "coach": { "title": "Coach", - "description": "Enable the Coach, tone and scope, stored memories.", "subtitle": "Tune the Coach, its proactive nudges, and its memory." }, "security": { @@ -5779,8 +5765,7 @@ }, "privacy": { "title": "Data & privacy", - "subtitle": "Your data, retention, encryption, and account security in one place.", - "description": "Export, deletion, retention, encryption status, sessions, and security activity." + "subtitle": "Your data, retention, encryption, and account security in one place." }, "mcp": { "title": "MCP connector", @@ -5788,8 +5773,7 @@ }, "anamnesis": { "title": "Medical history", - "subtitle": "Your medical background, lifestyle, emergency details and what the AI may read.", - "description": "Structured allergies and family history records." + "subtitle": "Your medical background, lifestyle, emergency details and what the AI may read." }, "environment": { "title": "Location & weather", diff --git a/messages/es.json b/messages/es.json index 69d433d5b..45dd83155 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5310,12 +5310,10 @@ "sections": { "account": { "title": "Cuenta", - "description": "Perfil, contraseña, claves de acceso y tu recorrido de incorporación.", "subtitle": "Tu perfil, identidad y datos de acceso." }, "integrations": { "title": "Integraciones", - "description": "Servicios conectados, canales de entrega y prioridad de fuentes en un solo lugar.", "subtitle": "Conecta y gestiona tus fuentes de datos y dispositivos.", "connectionsHeading": "Conexiones", "channelsHeading": "Canales de entrega", @@ -5324,7 +5322,6 @@ }, "notifications": { "title": "Notificaciones", - "description": "Todos los recordatorios en un solo lugar: qué se recuerda y cuándo, y dónde se entrega.", "subtitle": "Elige qué recordatorios recibes y dónde se entregan.", "remindersHeading": "Recordatorios", "matrixLink": "Notificaciones por canal", @@ -5332,7 +5329,6 @@ }, "layout": { "title": "Diseño", - "description": "Elige qué muestran el panel, los análisis y tus módulos de seguimiento, y cómo se organizan.", "backToHub": "Volver a Diseño", "dashboard": { "title": "Panel", @@ -5373,12 +5369,10 @@ }, "thresholds": { "title": "Objetivos", - "description": "Por métrica: tu propio rango objetivo de valores saludables.", "subtitle": "Define tus rangos objetivo personales por métrica." }, "sources": { "title": "Prioridad de fuentes", - "description": "Elige qué fuente prevalece cuando varias registran la misma métrica el mismo día.", "integrationsHint": "Conecta o desconecta fuentes de datos en la página de integraciones.", "integrationsHintLink": "Abrir integraciones", "cardTitle": "Orden por métrica", @@ -5465,22 +5459,18 @@ }, "ai": { "title": "Proveedor de IA", - "description": "Proveedor, modelo y clave API para los análisis, además de tu contexto «sobre mí».", "subtitle": "Elige tu proveedor de IA y usa tu propia clave." }, "api": { "title": "API y tokens", - "description": "Tokens Bearer para tus propios scripts y apps — registra mediciones o tomas de medicación desde cualquier lugar.", "subtitle": "Gestiona los endpoints de la API y los tokens de acceso." }, "advanced": { "title": "Avanzado", - "description": "La salida: borra todos los registros de salud para siempre o cierra la cuenta.", "subtitle": "Restablecer datos y eliminar cuenta." }, "export": { "title": "Exportar e importar", - "description": "Descarga tus datos como CSV o una copia de seguridad JSON completa, o importa desde Apple Health, JSON o CSV. El informe médico ahora está en Historia de salud.", "otherOptionsHeading": "Otras opciones de exportación", "downloadFailed": "No se pudo descargar la exportación. Inténtalo de nuevo.", "actions": { @@ -5725,12 +5715,10 @@ }, "about": { "title": "Acerca de esta app", - "description": "Versión, licencia, enlaces.", "subtitle": "Versión, licencia y enlaces del proyecto." }, "sharing": { "title": "Compartir", - "description": "Crea un enlace de solo lectura para compartir tu historial de salud con un profesional médico.", "subtitle": "Comparte acceso de solo lectura a tus datos." }, "insights": { @@ -5765,12 +5753,10 @@ }, "gesundheitsakte": { "title": "Historial médico", - "description": "Exporta tu historial médico completo como PDF, FHIR R4 o un paquete ZIP combinado.", "subtitle": "Exporta un historial de salud completo, listo para el médico." }, "coach": { "title": "Coach", - "description": "Activa el Coach, tono y alcance, recuerdos guardados.", "subtitle": "Ajusta el Coach, sus avisos proactivos y su memoria." }, "security": { @@ -5779,8 +5765,7 @@ }, "privacy": { "title": "Datos y privacidad", - "subtitle": "Tus datos, su retención, el cifrado y la seguridad de la cuenta en un solo lugar.", - "description": "Exportación, eliminación, retención, estado de cifrado, sesiones y actividad de seguridad." + "subtitle": "Tus datos, su retención, el cifrado y la seguridad de la cuenta en un solo lugar." }, "mcp": { "title": "Conector MCP", @@ -5788,8 +5773,7 @@ }, "anamnesis": { "title": "Antecedentes médicos", - "subtitle": "Tus antecedentes, tu estilo de vida, tus datos de emergencia y lo que la IA puede leer.", - "description": "Registros estructurados de alergias y antecedentes familiares." + "subtitle": "Tus antecedentes, tu estilo de vida, tus datos de emergencia y lo que la IA puede leer." }, "environment": { "title": "Ubicación y clima", diff --git a/messages/fr.json b/messages/fr.json index a21579d0d..49cbf7748 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5310,12 +5310,10 @@ "sections": { "account": { "title": "Compte", - "description": "Profil, mot de passe, clés d’accès et votre visite guidée.", "subtitle": "Votre profil, votre identité et vos identifiants." }, "integrations": { "title": "Intégrations", - "description": "Services connectés, canaux de diffusion et priorité des sources au même endroit.", "subtitle": "Connectez et gérez vos sources de données et appareils.", "connectionsHeading": "Connexions", "channelsHeading": "Canaux de diffusion", @@ -5324,7 +5322,6 @@ }, "notifications": { "title": "Notifications", - "description": "Tous les rappels au même endroit — quoi et quand, et où c'est envoyé.", "subtitle": "Choisissez les rappels que vous recevez et où ils sont envoyés.", "remindersHeading": "Rappels", "matrixLink": "Notifications par canal", @@ -5332,7 +5329,6 @@ }, "layout": { "title": "Disposition", - "description": "Choisissez ce qu'affichent le tableau de bord, les analyses et vos modules de suivi, et leur agencement.", "backToHub": "Retour à Disposition", "dashboard": { "title": "Tableau de bord", @@ -5373,12 +5369,10 @@ }, "thresholds": { "title": "Objectifs", - "description": "Par métrique : votre propre plage cible pour des valeurs saines.", "subtitle": "Définissez vos plages cibles personnelles par mesure." }, "sources": { "title": "Priorité des sources", - "description": "Choisissez quelle source prévaut quand plusieurs enregistrent la même métrique le même jour.", "integrationsHint": "Connectez ou déconnectez des sources de données sur la page Intégrations.", "integrationsHintLink": "Ouvrir les intégrations", "cardTitle": "Classement par métrique", @@ -5465,22 +5459,18 @@ }, "ai": { "title": "Fournisseur d'IA", - "description": "Fournisseur, modèle et clé API pour les analyses, ainsi que votre contexte « à propos de moi ».", "subtitle": "Choisissez votre fournisseur d'IA et utilisez votre propre clé." }, "api": { "title": "API et jetons", - "description": "Jetons Bearer pour vos propres scripts et applis — enregistrez des mesures ou des prises de médicaments depuis n’importe où.", "subtitle": "Gérez les points d'accès API et les jetons d'accès." }, "advanced": { "title": "Avancé", - "description": "La sortie : effacer définitivement toutes les données de santé ou fermer le compte.", "subtitle": "Réinitialisation des données et suppression du compte." }, "export": { "title": "Export et import", - "description": "Téléchargez vos données en CSV ou en sauvegarde JSON complète, ou importez depuis Apple Santé, JSON ou CSV. Le compte rendu médical se trouve désormais dans Dossier de santé.", "otherOptionsHeading": "Autres options d'export", "downloadFailed": "Échec du téléchargement de l'export. Veuillez réessayer.", "actions": { @@ -5725,12 +5715,10 @@ }, "about": { "title": "À propos de cette app", - "description": "Version, licence, liens.", "subtitle": "Version, licence et liens du projet." }, "sharing": { "title": "Partage", - "description": "Créez un lien en lecture seule pour partager votre dossier de santé avec un professionnel de santé.", "subtitle": "Partagez un accès en lecture seule à vos données." }, "insights": { @@ -5765,12 +5753,10 @@ }, "gesundheitsakte": { "title": "Dossier médical", - "description": "Exportez votre dossier médical complet au format PDF, FHIR R4 ou en paquet ZIP combiné.", "subtitle": "Exportez un dossier de santé complet, prêt pour le médecin." }, "coach": { "title": "Coach", - "description": "Activez le Coach, le ton et la portée, les souvenirs enregistrés.", "subtitle": "Réglez le Coach, ses suggestions proactives et sa mémoire." }, "security": { @@ -5779,8 +5765,7 @@ }, "privacy": { "title": "Données et confidentialité", - "subtitle": "Vos données, leur conservation, le chiffrement et la sécurité du compte au même endroit.", - "description": "Export, suppression, conservation, état du chiffrement, sessions et activité de sécurité." + "subtitle": "Vos données, leur conservation, le chiffrement et la sécurité du compte au même endroit." }, "mcp": { "title": "Connecteur MCP", @@ -5788,8 +5773,7 @@ }, "anamnesis": { "title": "Antécédents médicaux", - "subtitle": "Vos antécédents, votre mode de vie, vos informations d’urgence et ce que l’IA peut lire.", - "description": "Dossiers structurés des allergies et des antécédents familiaux." + "subtitle": "Vos antécédents, votre mode de vie, vos informations d’urgence et ce que l’IA peut lire." }, "environment": { "title": "Localisation et météo", diff --git a/messages/it.json b/messages/it.json index b901aa65a..7e31189e0 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5310,12 +5310,10 @@ "sections": { "account": { "title": "Account", - "description": "Profilo, password, passkey e il tour di benvenuto.", "subtitle": "Il tuo profilo, la tua identità e i dati di accesso." }, "integrations": { "title": "Integrazioni", - "description": "Servizi collegati, canali di consegna e priorità delle fonti in un unico posto.", "subtitle": "Collega e gestisci le tue fonti di dati e i dispositivi.", "connectionsHeading": "Connessioni", "channelsHeading": "Canali di recapito", @@ -5324,7 +5322,6 @@ }, "notifications": { "title": "Notifiche", - "description": "Tutti i promemoria in un unico posto — cosa e quando, e dove vengono recapitati.", "subtitle": "Scegli quali promemoria ricevere e dove vengono recapitati.", "remindersHeading": "Promemoria", "matrixLink": "Notifiche per canale", @@ -5332,7 +5329,6 @@ }, "layout": { "title": "Layout", - "description": "Scegli cosa mostrano dashboard, approfondimenti e i tuoi moduli di monitoraggio, e come sono disposti.", "backToHub": "Torna a Layout", "dashboard": { "title": "Cruscotto", @@ -5373,12 +5369,10 @@ }, "thresholds": { "title": "Obiettivi", - "description": "Per metrica: il tuo intervallo target per valori sani.", "subtitle": "Imposta i tuoi intervalli target personali per metrica." }, "sources": { "title": "Priorità delle fonti", - "description": "Scegli quale fonte prevale quando più di una registra la stessa metrica lo stesso giorno.", "integrationsHint": "Collega o scollega le fonti dati nella pagina Integrazioni.", "integrationsHintLink": "Apri le integrazioni", "cardTitle": "Ordine per metrica", @@ -5465,22 +5459,18 @@ }, "ai": { "title": "Provider IA", - "description": "Provider, modello e chiave API per gli approfondimenti, oltre al tuo contesto «su di me».", "subtitle": "Scegli il tuo provider IA e usa la tua chiave." }, "api": { "title": "API e token", - "description": "Token Bearer per i tuoi script e le tue app — registra misurazioni o assunzioni di farmaci da ovunque.", "subtitle": "Gestisci gli endpoint API e i token di accesso." }, "advanced": { "title": "Avanzate", - "description": "La via d'uscita: cancella per sempre tutti i dati di salute o chiudi l'account.", "subtitle": "Ripristino dati ed eliminazione account." }, "export": { "title": "Esporta e importa", - "description": "Scarica i tuoi dati come CSV o backup JSON completo, oppure importa da Apple Salute, JSON o CSV. Il referto medico ora si trova in Cartella sanitaria.", "otherOptionsHeading": "Altre opzioni di esportazione", "downloadFailed": "Impossibile scaricare l'esportazione. Riprova.", "actions": { @@ -5725,12 +5715,10 @@ }, "about": { "title": "Informazioni su questa app", - "description": "Versione, licenza, link.", "subtitle": "Versione, licenza e link al progetto." }, "sharing": { "title": "Condivisione", - "description": "Crea un link di sola lettura per condividere la tua cartella clinica con un medico.", "subtitle": "Condividi l'accesso in sola lettura ai tuoi dati." }, "insights": { @@ -5765,12 +5753,10 @@ }, "gesundheitsakte": { "title": "Cartella clinica", - "description": "Esporta la tua cartella clinica completa in PDF, FHIR R4 o in un pacchetto ZIP combinato.", "subtitle": "Esporta una cartella clinica completa, pronta per il medico." }, "coach": { "title": "Coach", - "description": "Attiva il Coach, tono e ambito, ricordi salvati.", "subtitle": "Regola il Coach, i suoi suggerimenti proattivi e la sua memoria." }, "security": { @@ -5779,8 +5765,7 @@ }, "privacy": { "title": "Dati e privacy", - "subtitle": "I tuoi dati, la conservazione, la cifratura e la sicurezza dell'account in un unico posto.", - "description": "Esportazione, eliminazione, conservazione, stato della cifratura, sessioni e attività di sicurezza." + "subtitle": "I tuoi dati, la conservazione, la cifratura e la sicurezza dell'account in un unico posto." }, "mcp": { "title": "Connettore MCP", @@ -5788,8 +5773,7 @@ }, "anamnesis": { "title": "Anamnesi", - "subtitle": "La tua anamnesi, lo stile di vita, i dati di emergenza e ciò che l’IA può leggere.", - "description": "Record strutturati di allergie e anamnesi familiare." + "subtitle": "La tua anamnesi, lo stile di vita, i dati di emergenza e ciò che l’IA può leggere." }, "environment": { "title": "Posizione e meteo", diff --git a/messages/ko.json b/messages/ko.json index 3ce0d4d83..32c245fac 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -5310,12 +5310,10 @@ "sections": { "account": { "title": "계정", - "description": "프로필, 비밀번호, 패스키, 시작 설정 둘러보기.", "subtitle": "프로필과 신원, 로그인 정보예요." }, "integrations": { "title": "연동", - "description": "연결된 서비스, 전송 채널, 소스 우선순위를 한곳에서.", "subtitle": "데이터 소스와 기기를 연결하고 관리해요.", "connectionsHeading": "연결", "channelsHeading": "전송 채널", @@ -5324,7 +5322,6 @@ }, "notifications": { "title": "알림", - "description": "모든 알림을 한곳에서 — 무엇을 언제 알릴지, 어디로 보낼지.", "subtitle": "받을 알림과 전달될 곳을 골라요.", "remindersHeading": "알림", "matrixLink": "채널별 알림", @@ -5332,7 +5329,6 @@ }, "layout": { "title": "화면 구성", - "description": "대시보드, 인사이트, 기록 모듈에 무엇을 보여주고 어떻게 배치할지 정해요.", "backToHub": "화면 구성으로 돌아가기", "dashboard": { "title": "대시보드", @@ -5373,12 +5369,10 @@ }, "thresholds": { "title": "목표", - "description": "지표별로 건강한 값의 목표 범위를 정해요.", "subtitle": "지표별 목표 범위를 직접 정해요." }, "sources": { "title": "소스 우선순위", - "description": "같은 날 같은 지표를 여러 소스가 기록했을 때 어느 소스를 쓸지 골라요.", "integrationsHint": "데이터 소스 연결과 해제는 연동 페이지에서 해요.", "integrationsHintLink": "연동 열기", "cardTitle": "지표별 순위", @@ -5465,22 +5459,18 @@ }, "ai": { "title": "AI 제공자", - "description": "인사이트에 쓸 제공자, 모델, API 키 — 그리고 '나에 대해' 컨텍스트.", "subtitle": "AI 제공자를 고르고 직접 키를 등록해요." }, "api": { "title": "API와 토큰", - "description": "내 스크립트와 앱에서 쓸 베어러 토큰 — 어디서든 측정값이나 약 복용을 기록해요.", "subtitle": "API 엔드포인트와 액세스 토큰을 관리해요." }, "advanced": { "title": "고급", - "description": "마지막 출구: 모든 건강 기록을 영구히 지우거나 계정을 완전히 닫아요.", "subtitle": "데이터 초기화와 계정 삭제." }, "export": { "title": "내보내기와 불러오기", - "description": "데이터를 CSV나 전체 JSON 백업으로 내려받고, Apple Health·JSON·CSV에서 불러와요. 진료용 리포트는 이제 건강 기록에 있어요.", "otherOptionsHeading": "다른 내보내기 옵션", "downloadFailed": "내보내기 파일을 내려받지 못했어요. 다시 시도해 주세요.", "actions": { @@ -5725,12 +5715,10 @@ }, "about": { "title": "앱 정보", - "description": "버전, 라이선스, 링크.", "subtitle": "버전, 라이선스, 프로젝트 링크." }, "sharing": { "title": "공유", - "description": "임상의와 건강 기록을 나눌 읽기 전용 링크를 만들어요.", "subtitle": "데이터를 읽기 전용으로 공유해요." }, "insights": { @@ -5765,12 +5753,10 @@ }, "gesundheitsakte": { "title": "건강 기록", - "description": "전체 건강 기록을 PDF, FHIR R4, 또는 하나로 묶은 ZIP으로 내보내요.", "subtitle": "임상의에게 바로 건넬 수 있는 완전한 건강 기록을 내보내요." }, "coach": { "title": "코치", - "description": "코치 켜기, 말투와 범위, 저장된 기억.", "subtitle": "코치의 말투, 먼저 건네는 알림, 기억을 조정해요." }, "security": { @@ -5779,8 +5765,7 @@ }, "privacy": { "title": "데이터와 개인정보", - "subtitle": "데이터, 보관 기간, 암호화, 계정 보안을 한곳에서.", - "description": "내보내기, 삭제, 보관 기간, 암호화 상태, 세션, 보안 활동." + "subtitle": "데이터, 보관 기간, 암호화, 계정 보안을 한곳에서." }, "mcp": { "title": "MCP 커넥터", @@ -5788,8 +5773,7 @@ }, "anamnesis": { "title": "병력", - "subtitle": "병력, 생활 습관, 응급 정보와 AI가 읽을 수 있는 범위를 관리해요.", - "description": "구조화된 알레르기·가족력 기록." + "subtitle": "병력, 생활 습관, 응급 정보와 AI가 읽을 수 있는 범위를 관리해요." }, "environment": { "title": "위치 및 날씨", diff --git a/messages/pl.json b/messages/pl.json index e67215a2f..286d25498 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5310,12 +5310,10 @@ "sections": { "account": { "title": "Konto", - "description": "Profil, hasło, klucze dostępu i przewodnik wprowadzający.", "subtitle": "Twój profil, tożsamość i dane logowania." }, "integrations": { "title": "Integracje", - "description": "Połączone usługi, kanały dostarczania i priorytet źródeł w jednym miejscu.", "subtitle": "Połącz i zarządzaj źródłami danych oraz urządzeniami.", "connectionsHeading": "Połączenia", "channelsHeading": "Kanały dostarczania", @@ -5324,7 +5322,6 @@ }, "notifications": { "title": "Powiadomienia", - "description": "Wszystkie przypomnienia w jednym miejscu — co i kiedy oraz gdzie są dostarczane.", "subtitle": "Wybierz, które przypomnienia otrzymujesz i gdzie są dostarczane.", "remindersHeading": "Przypomnienia", "matrixLink": "Powiadomienia wg kanału", @@ -5332,7 +5329,6 @@ }, "layout": { "title": "Układ", - "description": "Ustal, co pokazują pulpit, analizy i Twoje moduły śledzenia oraz jak są rozłożone.", "backToHub": "Powrót do układu", "dashboard": { "title": "Pulpit", @@ -5373,12 +5369,10 @@ }, "thresholds": { "title": "Cele", - "description": "Dla każdej metryki: własny zakres docelowy zdrowych wartości.", "subtitle": "Ustaw osobiste zakresy docelowe dla każdej metryki." }, "sources": { "title": "Priorytet źródeł", - "description": "Wybierz, które źródło wygrywa, gdy kilka rejestruje tę samą metrykę tego samego dnia.", "integrationsHint": "Źródła danych podłączysz lub odłączysz na stronie integracji.", "integrationsHintLink": "Otwórz integracje", "cardTitle": "Kolejność według metryki", @@ -5465,22 +5459,18 @@ }, "ai": { "title": "Dostawca AI", - "description": "Dostawca, model i klucz API do analiz, a także Twój kontekst „o mnie”.", "subtitle": "Wybierz dostawcę AI i użyj własnego klucza." }, "api": { "title": "API i tokeny", - "description": "Tokeny Bearer dla własnych skryptów i aplikacji — zapisuj pomiary lub przyjęcia leków z dowolnego miejsca.", "subtitle": "Zarządzaj punktami końcowymi API i tokenami dostępu." }, "advanced": { "title": "Zaawansowane", - "description": "Wyjście awaryjne: trwale usuń wszystkie dane zdrowotne albo zamknij konto.", "subtitle": "Reset danych i usunięcie konta." }, "export": { "title": "Eksport i import", - "description": "Pobierz swoje dane jako CSV lub pełną kopię zapasową JSON albo zaimportuj z Apple Health, JSON lub CSV. Raport dla lekarza znajdziesz teraz w Dokumentacji zdrowotnej.", "otherOptionsHeading": "Inne opcje eksportu", "downloadFailed": "Nie udało się pobrać eksportu. Spróbuj ponownie.", "actions": { @@ -5725,12 +5715,10 @@ }, "about": { "title": "O tej aplikacji", - "description": "Wersja, licencja, linki.", "subtitle": "Wersja, licencja i linki do projektu." }, "sharing": { "title": "Udostępnianie", - "description": "Utwórz link tylko do odczytu, aby udostępnić swoją dokumentację zdrowotną lekarzowi.", "subtitle": "Udostępnij dostęp tylko do odczytu do swoich danych." }, "insights": { @@ -5765,12 +5753,10 @@ }, "gesundheitsakte": { "title": "Dokumentacja zdrowotna", - "description": "Wyeksportuj pełną dokumentację zdrowotną jako PDF, FHIR R4 lub łączony pakiet ZIP.", "subtitle": "Eksportuj kompletną dokumentację zdrowotną gotową dla lekarza." }, "coach": { "title": "Coach", - "description": "Włącz Coacha, ton i zakres, zapisane wspomnienia.", "subtitle": "Dostosuj Coacha, jego proaktywne podpowiedzi i pamięć." }, "security": { @@ -5779,8 +5765,7 @@ }, "privacy": { "title": "Dane i prywatność", - "subtitle": "Twoje dane, retencja, szyfrowanie i bezpieczeństwo konta w jednym miejscu.", - "description": "Eksport, usuwanie, retencja, stan szyfrowania, sesje i aktywność zabezpieczeń." + "subtitle": "Twoje dane, retencja, szyfrowanie i bezpieczeństwo konta w jednym miejscu." }, "mcp": { "title": "Łącznik MCP", @@ -5788,8 +5773,7 @@ }, "anamnesis": { "title": "Wywiad medyczny", - "subtitle": "Twoja historia zdrowia, styl życia, dane alarmowe i to, co może czytać AI.", - "description": "Ustrukturyzowane rekordy alergii i wywiadu rodzinnego." + "subtitle": "Twoja historia zdrowia, styl życia, dane alarmowe i to, co może czytać AI." }, "environment": { "title": "Lokalizacja i pogoda", From 313acdf304cf6679d9c439c1bfd4c5549740f69d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 21:04:09 +0200 Subject: [PATCH 13/15] Size the source-priority skeleton to every metric line The loading state now reserves one collapsed line per metric, the upper bound of the loaded list, so the action row does not move down when the ladders arrive. --- src/components/settings/sources-section.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/settings/sources-section.tsx b/src/components/settings/sources-section.tsx index 68f5e54dd..57df0ca70 100644 --- a/src/components/settings/sources-section.tsx +++ b/src/components/settings/sources-section.tsx @@ -727,8 +727,10 @@ function SourcesSkeletonList() { - {[0, 1, 2, 3].map((i) => ( - + {/* One collapsed line per remaining metric: the upper bound, so the + action row below never jumps down when the ladders arrive. */} + {SOURCE_PRIORITY_METRIC_KEYS.slice(1).map((metric) => ( + ))} ); From 01f3a8b8756555953fa59de162a03d6300fa4d74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 21:10:06 +0200 Subject: [PATCH 14/15] Check the account page's language select where it now lives The mobile consistency check pinned the language select to the date of birth's grid row. It now asserts the select sits in the display card, outside the profile form, while the date of birth stays in the form. --- e2e/settings-mobile-consistency.spec.ts | 44 ++++++++++++++----------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/e2e/settings-mobile-consistency.spec.ts b/e2e/settings-mobile-consistency.spec.ts index b9b67519e..7294d69ba 100644 --- a/e2e/settings-mobile-consistency.spec.ts +++ b/e2e/settings-mobile-consistency.spec.ts @@ -149,38 +149,42 @@ test.describe("Settings mobile consistency (Pixel 5)", () => { } }); - test("/settings/account: Sprache select shares one grid row with date-of-birth", async ({ + test("/settings/account: the language select sits in the display card, outside the profile form", async ({ page, }) => { await page.goto("/settings/account", { waitUntil: "networkidle" }); - // Gate on the two elements this test is about, not on the network. An idle - // network says nothing about whether React has rendered the profile form, - // and the single `evaluate` below does not retry — so without this the - // test reads an empty document and reports "language + dob fields must - // exist", which sounds like a missing feature and is really a race. + // Gate on the elements this test is about, not on the network: the + // single `evaluate` below does not retry. await expect(page.locator("#language-select")).toBeAttached(); await expect(page.locator("#dob")).toBeAttached(); - // The v1.4.27 R1 settings audit pairs date-of-birth with language - // in a single `grid sm:grid-cols-2` row so the profile form keeps - // a uniform two-column rhythm and the language field no longer - // sits alone at the bottom with a `sm:max-w-xs` clamp. - const sharedGrid = await page.evaluate(() => { + // The language, unit and format selects save the moment they change, so + // they live in their own card with no save button. Inside the profile + // form they read as covered by its Save. Each card keeps a two-column + // grid: the language shares one with the units, the date of birth one + // with the timezone. + const layout = await page.evaluate(() => { const lang = document.getElementById("language-select"); const dob = document.getElementById("dob"); - if (!lang || !dob) return { found: false, sharedGrid: false }; - const langGrid = lang.closest('[class*="grid"]'); - const dobGrid = dob.closest('[class*="grid"]'); + if (!lang || !dob) return null; + const displayCard = document.querySelector( + '[data-testid="settings-display-card"]', + ); return { - found: true, - sharedGrid: langGrid !== null && langGrid === dobGrid, + langInDisplayCard: !!displayCard && displayCard.contains(lang), + langInForm: lang.closest("form") !== null, + dobInForm: dob.closest("form") !== null, + langHasGrid: lang.closest('[class*="grid"]') !== null, + dobHasGrid: dob.closest('[class*="grid"]') !== null, }; }); - expect(sharedGrid.found, "language + dob fields must exist").toBe(true); - expect(sharedGrid.sharedGrid, "language + dob must share a grid").toBe( - true, - ); + expect(layout, "language + dob fields must exist").not.toBeNull(); + expect(layout!.langInDisplayCard).toBe(true); + expect(layout!.langInForm).toBe(false); + expect(layout!.dobInForm).toBe(true); + expect(layout!.langHasGrid).toBe(true); + expect(layout!.dobHasGrid).toBe(true); }); test("/: Compare-to trigger renders at 44 px", async ({ page }) => { From fc2753ef79da3c39501126a28cf5cb98cc19f320 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 21:10:06 +0200 Subject: [PATCH 15/15] Let the delegated-write journey add through the empty list's control An empty measurement list carries its add action in the empty state, not the header, so the write-grant journey waited on a header button that never appeared. It takes whichever add control the page shows, the way the other add flows already do. --- e2e/v137-sharing-managed-profiles.spec.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/e2e/v137-sharing-managed-profiles.spec.ts b/e2e/v137-sharing-managed-profiles.spec.ts index 6cf1ffbd0..f4e225f74 100644 --- a/e2e/v137-sharing-managed-profiles.spec.ts +++ b/e2e/v137-sharing-managed-profiles.spec.ts @@ -503,13 +503,20 @@ test.describe.serial("scoped sharing browser journeys", () => { await expect(banner).toHaveAttribute("data-record-kind", "shared"); await page.goto("/measurements"); - await expect(page.locator('[data-slot="measurement-add"]')).toBeVisible(); + // An empty list carries its add action in the empty state rather than + // the header, so the write grant shows whichever of the two applies. + const addControl = page + .locator( + '[data-slot="measurement-add"], [data-slot="measurement-add-first"]', + ) + .first(); + await expect(addControl).toBeVisible(); const post = page.waitForResponse( (response) => response.request().method() === "POST" && new URL(response.url()).pathname === "/api/measurements", ); - await page.locator('[data-slot="measurement-add"]').click(); + await addControl.click(); await page.locator("#sys").fill("124"); await page.locator("#dia").fill("78"); await page.getByRole("button", { name: /^save$/i }).click();