mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Merge pull request #393 from MBombeck/release/v1.25.11
release: v1.25.11 — consistent settings and cards
This commit is contained in:
88 files changed
+1901
-754
No files matched your search
@@ -2,6 +2,28 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.25.11] — 2026-06-30 — Consistent settings and cards
|
||||
|
||||
### Added
|
||||
|
||||
- A date field now opens an in-app calendar that looks and behaves the same on every browser and phone, instead of handing off to the device's own date picker. The time field — and your 12-/24-hour preference — are unchanged.
|
||||
- Appearance settings are now a hub: a list of the modules you can arrange (dashboard, insights, medications, and the rest). Pick one to open its own page with that module's view and ordering options, plus a link back to the list.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Medication doses now show their unit in your language ("1 Stück", "2 tablets") instead of an English code, and the dose sits on its own line under the name instead of running into it — on the cards, the detail view and the dose lists.
|
||||
- The integrations settings could intermittently render with non-interactive notification controls after a slow load; the notification card now renders identically on the server and on the first client paint, so its controls stay responsive.
|
||||
- A short settings section no longer over-scrolls into an empty band below its last card — the content now fills the visible height exactly, and switching sections stays width-stable.
|
||||
- The admin sign-in overview truncates a long IPv6 address instead of forcing the table to scroll sideways, and clicking an address copies it in full.
|
||||
- The admin system snapshot now names the geo provider you actually configured, rather than always showing the default.
|
||||
- Several insight charts no longer leave a lone tile stranded at half width, and a chart's rolling-average line no longer sits flush against the card edge.
|
||||
|
||||
### Changed
|
||||
|
||||
- Settings section headers, card layouts and add-form spacing are now drawn from shared building blocks, so they stay consistent across the app — the medication card's shape is the template every card follows.
|
||||
- Configuration and secret fields — API keys, tokens, integration URLs — no longer prompt your password manager to fill or save them, while real sign-in and password fields still do.
|
||||
- The custom-metrics add button carries an accessible label on mobile, and the custom-metrics section reads as its own block on the measurements page.
|
||||
|
||||
## [1.25.10] — 2026-06-30 — Mobile add-form fix
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.25.10
|
||||
version: 1.25.11
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
|
||||
+8
-3
@@ -4039,6 +4039,8 @@
|
||||
"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",
|
||||
"connectionsDescription": "Datenquellen und Geräte verbinden und verwalten.",
|
||||
"channelsHeading": "Zustellkanäle",
|
||||
"channelsDescription": "Wohin Erinnerungen und Hinweise zugestellt werden."
|
||||
},
|
||||
@@ -5435,7 +5437,7 @@
|
||||
"integrationWebPush": "Web Push",
|
||||
"offlineGeoLabel": "Geodaten",
|
||||
"offlineGeoEnabled": "Offline (GeoLite2)",
|
||||
"offlineGeoFallback": "Fallback (ipwho.is) — MAXMIND_LICENSE_KEY in CI setzen",
|
||||
"offlineGeoFallback": "Online ({host}) — MAXMIND_LICENSE_KEY in CI setzen",
|
||||
"apiGlobal": "API-Endpunkte und API-Tokens erlauben.",
|
||||
"umamiTitle": "Umami",
|
||||
"umamiDescription": "Web-Analyse mit Umami",
|
||||
@@ -5671,7 +5673,7 @@
|
||||
"snapshotBuiltAt": "Gebaut am",
|
||||
"snapshotOfflineGeo": "Geodaten",
|
||||
"snapshotOfflineGeoOn": "Offline (GeoLite2)",
|
||||
"snapshotOfflineGeoOff": "Fallback (ipwho.is)",
|
||||
"snapshotOfflineGeoOff": "Online ({host})",
|
||||
"versionTileTitle": "Version",
|
||||
"versionTileVersion": "Laufende Version",
|
||||
"versionTileBuildSha": "Build-SHA",
|
||||
@@ -5881,7 +5883,10 @@
|
||||
"mfaEnforcedOn": "MFA an",
|
||||
"mfaEnforcedOff": "MFA aus",
|
||||
"mfaEnforcedOnHint": "Für diesen Nutzer ist ein zweiter Faktor erforderlich. Klicken, um die Anforderung aufzuheben.",
|
||||
"mfaEnforcedOffHint": "Einen zweiten Faktor für diesen Nutzer verlangen."
|
||||
"mfaEnforcedOffHint": "Einen zweiten Faktor für diesen Nutzer verlangen.",
|
||||
"copyIp": "IP-Adresse kopieren",
|
||||
"ipCopied": "IP-Adresse kopiert",
|
||||
"copyFailed": "Kopieren fehlgeschlagen."
|
||||
},
|
||||
"achievements": {
|
||||
"title": "Erfolge",
|
||||
|
||||
+8
-3
@@ -4039,6 +4039,8 @@
|
||||
"title": "Integrations",
|
||||
"description": "Connected services, delivery channels, and source priority in one place.",
|
||||
"subtitle": "Connect and manage your data sources and devices.",
|
||||
"connectionsHeading": "Connections",
|
||||
"connectionsDescription": "Connect and manage your data sources and devices.",
|
||||
"channelsHeading": "Delivery channels",
|
||||
"channelsDescription": "Where reminders and alerts are delivered."
|
||||
},
|
||||
@@ -5435,7 +5437,7 @@
|
||||
"integrationWebPush": "Web Push",
|
||||
"offlineGeoLabel": "Geo data",
|
||||
"offlineGeoEnabled": "Offline (GeoLite2)",
|
||||
"offlineGeoFallback": "Fallback (ipwho.is) — set MAXMIND_LICENSE_KEY in CI",
|
||||
"offlineGeoFallback": "Online ({host}) — set MAXMIND_LICENSE_KEY in CI",
|
||||
"apiGlobal": "Allow API endpoints and API tokens.",
|
||||
"umamiTitle": "Umami",
|
||||
"umamiDescription": "Web analytics with Umami",
|
||||
@@ -5671,7 +5673,7 @@
|
||||
"snapshotBuiltAt": "Built at",
|
||||
"snapshotOfflineGeo": "Geo data",
|
||||
"snapshotOfflineGeoOn": "Offline (GeoLite2)",
|
||||
"snapshotOfflineGeoOff": "Fallback (ipwho.is)",
|
||||
"snapshotOfflineGeoOff": "Online ({host})",
|
||||
"versionTileTitle": "Version",
|
||||
"versionTileVersion": "Running version",
|
||||
"versionTileBuildSha": "Build SHA",
|
||||
@@ -5881,7 +5883,10 @@
|
||||
"mfaEnforcedOn": "MFA on",
|
||||
"mfaEnforcedOff": "MFA off",
|
||||
"mfaEnforcedOnHint": "A second factor is required for this user. Click to stop requiring it.",
|
||||
"mfaEnforcedOffHint": "Require a second factor for this user."
|
||||
"mfaEnforcedOffHint": "Require a second factor for this user.",
|
||||
"copyIp": "Copy IP address",
|
||||
"ipCopied": "IP address copied",
|
||||
"copyFailed": "Could not copy."
|
||||
},
|
||||
"achievements": {
|
||||
"title": "Achievements",
|
||||
|
||||
+8
-3
@@ -4039,6 +4039,8 @@
|
||||
"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",
|
||||
"connectionsDescription": "Conecta y gestiona tus fuentes de datos y dispositivos.",
|
||||
"channelsHeading": "Canales de entrega",
|
||||
"channelsDescription": "Dónde se entregan los recordatorios y avisos."
|
||||
},
|
||||
@@ -5435,7 +5437,7 @@
|
||||
"integrationWebPush": "Web Push",
|
||||
"offlineGeoLabel": "Datos geográficos",
|
||||
"offlineGeoEnabled": "Sin conexión (GeoLite2)",
|
||||
"offlineGeoFallback": "Respaldo (ipwho.is) — configura MAXMIND_LICENSE_KEY en CI",
|
||||
"offlineGeoFallback": "En línea ({host}) — configura MAXMIND_LICENSE_KEY en CI",
|
||||
"apiGlobal": "Permitir endpoints de API y tokens de API.",
|
||||
"umamiTitle": "Umami",
|
||||
"umamiDescription": "Analítica web con Umami",
|
||||
@@ -5671,7 +5673,7 @@
|
||||
"snapshotBuiltAt": "Compilado el",
|
||||
"snapshotOfflineGeo": "Datos geográficos",
|
||||
"snapshotOfflineGeoOn": "Sin conexión (GeoLite2)",
|
||||
"snapshotOfflineGeoOff": "Respaldo (ipwho.is)",
|
||||
"snapshotOfflineGeoOff": "En línea ({host})",
|
||||
"versionTileTitle": "Versión",
|
||||
"versionTileVersion": "Versión en ejecución",
|
||||
"versionTileBuildSha": "SHA de compilación",
|
||||
@@ -5881,7 +5883,10 @@
|
||||
"mfaEnforcedOn": "MFA activado",
|
||||
"mfaEnforcedOff": "MFA desactivado",
|
||||
"mfaEnforcedOnHint": "Este usuario debe usar un segundo factor. Haz clic para dejar de exigirlo.",
|
||||
"mfaEnforcedOffHint": "Exigir un segundo factor a este usuario."
|
||||
"mfaEnforcedOffHint": "Exigir un segundo factor a este usuario.",
|
||||
"copyIp": "Copiar dirección IP",
|
||||
"ipCopied": "Dirección IP copiada",
|
||||
"copyFailed": "No se pudo copiar."
|
||||
},
|
||||
"achievements": {
|
||||
"title": "Logros",
|
||||
|
||||
+8
-3
@@ -4039,6 +4039,8 @@
|
||||
"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",
|
||||
"connectionsDescription": "Connectez et gérez vos sources de données et appareils.",
|
||||
"channelsHeading": "Canaux de diffusion",
|
||||
"channelsDescription": "Où sont envoyés les rappels et alertes."
|
||||
},
|
||||
@@ -5435,7 +5437,7 @@
|
||||
"integrationWebPush": "Web Push",
|
||||
"offlineGeoLabel": "Données géographiques",
|
||||
"offlineGeoEnabled": "Hors ligne (GeoLite2)",
|
||||
"offlineGeoFallback": "Fallback (ipwho.is) — définissez MAXMIND_LICENSE_KEY dans la CI",
|
||||
"offlineGeoFallback": "En ligne ({host}) — définissez MAXMIND_LICENSE_KEY dans la CI",
|
||||
"apiGlobal": "Autoriser les points de terminaison d'API et les jetons d'API.",
|
||||
"umamiTitle": "Umami",
|
||||
"umamiDescription": "Analyse web avec Umami",
|
||||
@@ -5671,7 +5673,7 @@
|
||||
"snapshotBuiltAt": "Compilé le",
|
||||
"snapshotOfflineGeo": "Données géographiques",
|
||||
"snapshotOfflineGeoOn": "Hors ligne (GeoLite2)",
|
||||
"snapshotOfflineGeoOff": "Fallback (ipwho.is)",
|
||||
"snapshotOfflineGeoOff": "En ligne ({host})",
|
||||
"versionTileTitle": "Version",
|
||||
"versionTileVersion": "Version en cours",
|
||||
"versionTileBuildSha": "SHA de build",
|
||||
@@ -5881,7 +5883,10 @@
|
||||
"mfaEnforcedOn": "MFA activé",
|
||||
"mfaEnforcedOff": "MFA désactivé",
|
||||
"mfaEnforcedOnHint": "Un second facteur est requis pour cet utilisateur. Cliquez pour ne plus l'exiger.",
|
||||
"mfaEnforcedOffHint": "Exiger un second facteur pour cet utilisateur."
|
||||
"mfaEnforcedOffHint": "Exiger un second facteur pour cet utilisateur.",
|
||||
"copyIp": "Copier l’adresse IP",
|
||||
"ipCopied": "Adresse IP copiée",
|
||||
"copyFailed": "Impossible de copier."
|
||||
},
|
||||
"achievements": {
|
||||
"title": "Réussites",
|
||||
|
||||
+8
-3
@@ -4039,6 +4039,8 @@
|
||||
"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",
|
||||
"connectionsDescription": "Collega e gestisci le tue fonti di dati e i dispositivi.",
|
||||
"channelsHeading": "Canali di recapito",
|
||||
"channelsDescription": "Dove vengono recapitati promemoria e avvisi."
|
||||
},
|
||||
@@ -5435,7 +5437,7 @@
|
||||
"integrationWebPush": "Web Push",
|
||||
"offlineGeoLabel": "Dati geografici",
|
||||
"offlineGeoEnabled": "Offline (GeoLite2)",
|
||||
"offlineGeoFallback": "Fallback (ipwho.is) — imposta MAXMIND_LICENSE_KEY in CI",
|
||||
"offlineGeoFallback": "Online ({host}) — imposta MAXMIND_LICENSE_KEY in CI",
|
||||
"apiGlobal": "Consentire endpoint API e token API.",
|
||||
"umamiTitle": "Umami",
|
||||
"umamiDescription": "Analisi web con Umami",
|
||||
@@ -5671,7 +5673,7 @@
|
||||
"snapshotBuiltAt": "Compilato il",
|
||||
"snapshotOfflineGeo": "Dati geografici",
|
||||
"snapshotOfflineGeoOn": "Offline (GeoLite2)",
|
||||
"snapshotOfflineGeoOff": "Fallback (ipwho.is)",
|
||||
"snapshotOfflineGeoOff": "Online ({host})",
|
||||
"versionTileTitle": "Versione",
|
||||
"versionTileVersion": "Versione in esecuzione",
|
||||
"versionTileBuildSha": "SHA build",
|
||||
@@ -5881,7 +5883,10 @@
|
||||
"mfaEnforcedOn": "MFA attivo",
|
||||
"mfaEnforcedOff": "MFA disattivato",
|
||||
"mfaEnforcedOnHint": "Per questo utente è richiesto un secondo fattore. Clicca per non richiederlo più.",
|
||||
"mfaEnforcedOffHint": "Richiedi un secondo fattore per questo utente."
|
||||
"mfaEnforcedOffHint": "Richiedi un secondo fattore per questo utente.",
|
||||
"copyIp": "Copia indirizzo IP",
|
||||
"ipCopied": "Indirizzo IP copiato",
|
||||
"copyFailed": "Impossibile copiare."
|
||||
},
|
||||
"achievements": {
|
||||
"title": "Obiettivi",
|
||||
|
||||
+8
-3
@@ -4039,6 +4039,8 @@
|
||||
"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",
|
||||
"connectionsDescription": "Połącz i zarządzaj źródłami danych oraz urządzeniami.",
|
||||
"channelsHeading": "Kanały dostarczania",
|
||||
"channelsDescription": "Gdzie dostarczane są przypomnienia i powiadomienia."
|
||||
},
|
||||
@@ -5435,7 +5437,7 @@
|
||||
"integrationWebPush": "Web Push",
|
||||
"offlineGeoLabel": "Dane geograficzne",
|
||||
"offlineGeoEnabled": "Offline (GeoLite2)",
|
||||
"offlineGeoFallback": "Awaryjne (ipwho.is) — ustaw MAXMIND_LICENSE_KEY w CI",
|
||||
"offlineGeoFallback": "Online ({host}) — ustaw MAXMIND_LICENSE_KEY w CI",
|
||||
"apiGlobal": "Zezwól na punkty końcowe API i tokeny API.",
|
||||
"umamiTitle": "Umami",
|
||||
"umamiDescription": "Analityka internetowa z Umami",
|
||||
@@ -5671,7 +5673,7 @@
|
||||
"snapshotBuiltAt": "Skompilowano",
|
||||
"snapshotOfflineGeo": "Dane geograficzne",
|
||||
"snapshotOfflineGeoOn": "Offline (GeoLite2)",
|
||||
"snapshotOfflineGeoOff": "Awaryjne (ipwho.is)",
|
||||
"snapshotOfflineGeoOff": "Online ({host})",
|
||||
"versionTileTitle": "Wersja",
|
||||
"versionTileVersion": "Uruchomiona wersja",
|
||||
"versionTileBuildSha": "SHA kompilacji",
|
||||
@@ -5881,7 +5883,10 @@
|
||||
"mfaEnforcedOn": "MFA wł.",
|
||||
"mfaEnforcedOff": "MFA wył.",
|
||||
"mfaEnforcedOnHint": "Ten użytkownik musi używać drugiego składnika. Kliknij, aby przestać go wymagać.",
|
||||
"mfaEnforcedOffHint": "Wymagaj drugiego składnika dla tego użytkownika."
|
||||
"mfaEnforcedOffHint": "Wymagaj drugiego składnika dla tego użytkownika.",
|
||||
"copyIp": "Kopiuj adres IP",
|
||||
"ipCopied": "Skopiowano adres IP",
|
||||
"copyFailed": "Nie udało się skopiować."
|
||||
},
|
||||
"achievements": {
|
||||
"title": "Osiągnięcia",
|
||||
|
||||
+22
-10
@@ -144,34 +144,46 @@ const nextConfig: NextConfig = {
|
||||
destination: "/settings/integrations#channels",
|
||||
permanent: true,
|
||||
},
|
||||
// v1.25.7 — the per-module view/sort/order surfaces (Medikamente,
|
||||
// Stimmung, Labor, Krankheit, Vorsorge) folded into "Darstellung"
|
||||
// (`/settings/layout`) as inline anchored sections. Their standalone
|
||||
// routes 301-redirect to the matching anchor so bookmarks, the PWA's
|
||||
// cached navigation, and the per-module page-header cogs keep resolving.
|
||||
// v1.25.11 (#148) — "Darstellung" (`/settings/layout`) is a HUB that lists
|
||||
// each module's view/sort/order surface; every module now lives on its own
|
||||
// subpage at `/settings/layout/<module>`. The legacy per-module routes
|
||||
// 301-redirect to the matching SUBPAGE (no longer an in-page anchor) so
|
||||
// bookmarks, the PWA's cached navigation, and the per-module page-header
|
||||
// cogs keep resolving. `dashboard` + `insights` join the redirect set so
|
||||
// all seven modules share one canonical URL shape.
|
||||
{
|
||||
source: "/settings/dashboard",
|
||||
destination: "/settings/layout/dashboard",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/settings/insights",
|
||||
destination: "/settings/layout/insights",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/settings/medications",
|
||||
destination: "/settings/layout#medications",
|
||||
destination: "/settings/layout/medications",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/settings/mood",
|
||||
destination: "/settings/layout#mood",
|
||||
destination: "/settings/layout/mood",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/settings/labs",
|
||||
destination: "/settings/layout#labs",
|
||||
destination: "/settings/layout/labs",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/settings/illness",
|
||||
destination: "/settings/layout#illness",
|
||||
destination: "/settings/layout/illness",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/settings/vorsorge",
|
||||
destination: "/settings/layout#vorsorge",
|
||||
destination: "/settings/layout/vorsorge",
|
||||
permanent: true,
|
||||
},
|
||||
// v1.25.7 — clinician share links fold into the Gesundheitsakte section
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.25.10",
|
||||
"version": "1.25.11",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"homepage": "https://healthlog.dev",
|
||||
@@ -67,6 +67,7 @@
|
||||
"@types/sax": "^1.2.7",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.4.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"fflate": "^0.8.3",
|
||||
"jspdf": "^4.2.1",
|
||||
@@ -83,6 +84,7 @@
|
||||
"qrcode": "^1.5.4",
|
||||
"radix-ui": "^1.6.0",
|
||||
"react": "19.2.7",
|
||||
"react-day-picker": "^10.0.1",
|
||||
"react-dom": "19.2.7",
|
||||
"react-hook-form": "^7.78.0",
|
||||
"recharts": "^3.8.1",
|
||||
|
||||
Generated
+39
-5
@@ -60,6 +60,9 @@ importers:
|
||||
clsx:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
date-fns:
|
||||
specifier: ^4.4.0
|
||||
version: 4.4.0
|
||||
dotenv:
|
||||
specifier: ^17.4.2
|
||||
version: 17.4.2
|
||||
@@ -80,7 +83,7 @@ importers:
|
||||
version: 3.0.2
|
||||
next:
|
||||
specifier: 16.2.9
|
||||
version: 16.2.9(@babel/core@7.29.7)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
version: 16.2.9(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
next-themes:
|
||||
specifier: ^0.4.6
|
||||
version: 0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
@@ -108,6 +111,9 @@ importers:
|
||||
react:
|
||||
specifier: 19.2.7
|
||||
version: 19.2.7
|
||||
react-day-picker:
|
||||
specifier: ^10.0.1
|
||||
version: 10.0.1(@types/react@19.2.14)(react@19.2.7)
|
||||
react-dom:
|
||||
specifier: 19.2.7
|
||||
version: 19.2.7(react@19.2.7)
|
||||
@@ -564,6 +570,9 @@ packages:
|
||||
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@date-fns/tz@1.5.0':
|
||||
resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==}
|
||||
|
||||
'@discoveryjs/json-ext@0.5.7':
|
||||
resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
@@ -3777,6 +3786,9 @@ packages:
|
||||
resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
date-fns@4.4.0:
|
||||
resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==}
|
||||
|
||||
debounce@1.2.1:
|
||||
resolution: {integrity: sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==}
|
||||
|
||||
@@ -5776,6 +5788,16 @@ packages:
|
||||
rc9@3.0.1:
|
||||
resolution: {integrity: sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==}
|
||||
|
||||
react-day-picker@10.0.1:
|
||||
resolution: {integrity: sha512-eNh6BlwcYInWaJtRv18mXQ06Ys/H6rdTZAnTaSdOYJuTpwP1JMCHNd1FDRadA+gbeinq+psdULN5Xnowy9mV8w==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
'@types/react': '>=16.8.0'
|
||||
react: '>=16.8.0'
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
react-dom@19.2.7:
|
||||
resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==}
|
||||
peerDependencies:
|
||||
@@ -7339,6 +7361,8 @@ snapshots:
|
||||
|
||||
'@bcoe/v8-coverage@1.0.2': {}
|
||||
|
||||
'@date-fns/tz@1.5.0': {}
|
||||
|
||||
'@discoveryjs/json-ext@0.5.7': {}
|
||||
|
||||
'@dnd-kit/accessibility@3.1.1(react@19.2.7)':
|
||||
@@ -10393,6 +10417,8 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
is-data-view: 1.0.2
|
||||
|
||||
date-fns@4.4.0: {}
|
||||
|
||||
debounce@1.2.1: {}
|
||||
|
||||
debug@3.2.7:
|
||||
@@ -11944,7 +11970,7 @@ snapshots:
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
|
||||
next@16.2.9(@babel/core@7.29.7)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
|
||||
next@16.2.9(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
|
||||
dependencies:
|
||||
'@next/env': 16.2.9
|
||||
'@swc/helpers': 0.5.15
|
||||
@@ -11953,7 +11979,7 @@ snapshots:
|
||||
postcss: 8.4.31
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.2.7)
|
||||
styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.7)
|
||||
optionalDependencies:
|
||||
'@next/swc-darwin-arm64': 16.2.9
|
||||
'@next/swc-darwin-x64': 16.2.9
|
||||
@@ -12534,6 +12560,14 @@ snapshots:
|
||||
defu: 6.1.7
|
||||
destr: 2.0.5
|
||||
|
||||
react-day-picker@10.0.1(@types/react@19.2.14)(react@19.2.7):
|
||||
dependencies:
|
||||
'@date-fns/tz': 1.5.0
|
||||
date-fns: 4.4.0
|
||||
react: 19.2.7
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
react-dom@19.2.7(react@19.2.7):
|
||||
dependencies:
|
||||
react: 19.2.7
|
||||
@@ -13143,12 +13177,12 @@ snapshots:
|
||||
|
||||
strip-json-comments@5.0.3: {}
|
||||
|
||||
styled-jsx@5.1.6(@babel/core@7.29.7)(react@19.2.7):
|
||||
styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.7):
|
||||
dependencies:
|
||||
client-only: 0.0.1
|
||||
react: 19.2.7
|
||||
optionalDependencies:
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/core': 7.29.0
|
||||
|
||||
supports-color@7.2.0:
|
||||
dependencies:
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ try {
|
||||
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
|
||||
const CACHE_VERSION =
|
||||
(typeof self !== "undefined" && self.__APP_VERSION__) ||
|
||||
/* @sw-version-fallback */ "v1.25.10";
|
||||
/* @sw-version-fallback */ "v1.25.11";
|
||||
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
|
||||
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
|
||||
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
|
||||
|
||||
@@ -5,8 +5,10 @@ vi.mock("@/lib/api-handler", () => ({
|
||||
}));
|
||||
|
||||
const offlineGeoReadyMock = vi.fn(() => false);
|
||||
const resolveGeoProviderHostMock = vi.fn(() => "ipwho.is");
|
||||
vi.mock("@/lib/geo", () => ({
|
||||
offlineGeoReady: () => offlineGeoReadyMock(),
|
||||
resolveGeoProviderHost: () => resolveGeoProviderHostMock(),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
@@ -16,6 +18,7 @@ beforeEach(() => {
|
||||
delete process.env.NEXT_PUBLIC_APP_BUILT_AT;
|
||||
delete process.env.NEXT_PUBLIC_APP_VERSION;
|
||||
offlineGeoReadyMock.mockReturnValue(false);
|
||||
resolveGeoProviderHostMock.mockReturnValue("ipwho.is");
|
||||
});
|
||||
|
||||
interface VersionEnvelope {
|
||||
@@ -28,6 +31,7 @@ interface VersionEnvelope {
|
||||
changelog: string;
|
||||
docs: string;
|
||||
offlineGeoEnabled: boolean;
|
||||
geoProviderHost: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -95,4 +99,11 @@ describe("GET /api/version", () => {
|
||||
const body = (await response.json()) as VersionEnvelope;
|
||||
expect(body.data.offlineGeoEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it("surfaces the resolved online geo provider host", async () => {
|
||||
resolveGeoProviderHostMock.mockReturnValue("ip-api.com");
|
||||
const response = await (GET as unknown as () => Promise<Response>)();
|
||||
const body = (await response.json()) as VersionEnvelope;
|
||||
expect(body.data.geoProviderHost).toBe("ip-api.com");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { offlineGeoReady } from "@/lib/geo";
|
||||
import { offlineGeoReady, resolveGeoProviderHost } from "@/lib/geo";
|
||||
import packageJson from "../../../../package.json";
|
||||
|
||||
// v1.4.27 R5 — `offlineGeoEnabled` reads from the same source the geo
|
||||
@@ -32,9 +32,11 @@ export const dynamic = "force-dynamic";
|
||||
*
|
||||
* `offlineGeoEnabled` is `true` when the GeoLite2-City MMDB is present
|
||||
* at the configured `GEOLITE2_DIR` and no `.empty` marker is set.
|
||||
* When `false` the runtime falls back to the online `ipwho.is`
|
||||
* provider and the resolver emits a one-shot admin notification on
|
||||
* first use.
|
||||
* When `false` the runtime falls back to the online provider named by
|
||||
* `geoProviderHost` (the host of `IP_GEO_LOOKUP_URL`, default
|
||||
* `ipwho.is`) and the resolver emits a one-shot admin notification on
|
||||
* first use. `geoProviderHost` lets the admin status surface name the
|
||||
* real provider instead of assuming the default.
|
||||
*/
|
||||
export const GET = apiHandler(async () => {
|
||||
// v1.4.43 B11 — prefer the build-arg-injected env var so the runtime
|
||||
@@ -50,6 +52,7 @@ export const GET = apiHandler(async () => {
|
||||
const buildSha = process.env.NEXT_PUBLIC_APP_BUILD_SHA?.trim() || null;
|
||||
const builtAt = process.env.NEXT_PUBLIC_APP_BUILT_AT?.trim() || null;
|
||||
const offlineGeoEnabled = offlineGeoReady();
|
||||
const geoProviderHost = resolveGeoProviderHost();
|
||||
|
||||
return apiSuccess({
|
||||
version,
|
||||
@@ -60,5 +63,6 @@ export const GET = apiHandler(async () => {
|
||||
changelog: "https://github.com/MBombeck/HealthLog/blob/main/CHANGELOG.md",
|
||||
docs: "https://docs.healthlog.dev",
|
||||
offlineGeoEnabled,
|
||||
geoProviderHost,
|
||||
});
|
||||
});
|
||||
@@ -570,7 +570,9 @@ export default function InsightsPage() {
|
||||
description={t("insights.editMode.emptyDescription")}
|
||||
action={
|
||||
<Button size="sm" asChild>
|
||||
<Link href="/settings/insights">{t("insights.customize")}</Link>
|
||||
<Link href="/settings/layout/insights">
|
||||
{t("insights.customize")}
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -90,7 +90,7 @@ export default function LabsPage() {
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
>
|
||||
<Link
|
||||
href="/settings/labs"
|
||||
href="/settings/layout/labs"
|
||||
aria-label={t("labs.customize")}
|
||||
title={t("labs.customize")}
|
||||
>
|
||||
|
||||
@@ -123,12 +123,14 @@ describe("/medications — view renders from the persisted preference", () => {
|
||||
expect(html).not.toContain('data-slot="medications-view-table"');
|
||||
});
|
||||
|
||||
it("links the header customize glyph to /settings/medications", () => {
|
||||
it("links the header customize glyph to /settings/layout/medications", () => {
|
||||
// v1.16.11 — same entry-point logic as the dashboard and insights
|
||||
// headers: one wrench ghost icon linking to the page's settings
|
||||
// section (view preference + manual order live there).
|
||||
// v1.25.11 (#148) — the per-module settings now live on the Appearance
|
||||
// subpage `/settings/layout/medications`.
|
||||
const html = renderPage({ version: 1, view: "cards", order: [] });
|
||||
expect(html).toContain('href="/settings/medications"');
|
||||
expect(html).toContain('href="/settings/layout/medications"');
|
||||
expect(html).toContain('aria-label="Customize medications"');
|
||||
// The in-page order dialog is gone — no dialog trigger remains.
|
||||
expect(html).not.toContain('aria-label="Adjust order"');
|
||||
|
||||
@@ -370,7 +370,7 @@ export default function MedicationsPage() {
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
>
|
||||
<Link
|
||||
href="/settings/medications"
|
||||
href="/settings/layout/medications"
|
||||
aria-label={t("medications.customize")}
|
||||
title={t("medications.customize")}
|
||||
>
|
||||
|
||||
@@ -90,7 +90,7 @@ export default function MoodPage() {
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
>
|
||||
<Link
|
||||
href="/settings/mood"
|
||||
href="/settings/layout/mood"
|
||||
aria-label={t("mood.customize")}
|
||||
title={t("mood.customize")}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { LayoutModuleGate } from "@/components/settings/layout-module-gate";
|
||||
import {
|
||||
LAYOUT_GROUPS,
|
||||
LAYOUT_GROUP_IDS,
|
||||
isLayoutGroupId,
|
||||
} from "@/components/settings/layout-groups";
|
||||
import { SettingsHubBackLink } from "@/components/settings/settings-hub-back-link";
|
||||
import { SettingsShell } from "@/components/settings/settings-shell";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
|
||||
/**
|
||||
* v1.25.11 (#148) — per-module Appearance subpage.
|
||||
*
|
||||
* `/settings/layout/<module>` renders ONLY that module's section (the existing
|
||||
* `*Section` component, verbatim) under the Appearance hub, with a
|
||||
* "← Appearance" back-link. The hub at `/settings/layout` lists the modules and
|
||||
* links here; the legacy `/settings/<module>` routes 301-redirect here
|
||||
* (next.config.ts).
|
||||
*
|
||||
* Each subpage is pre-rendered for the known module ids via
|
||||
* `generateStaticParams()`; `dynamicParams = false` 404s any other segment at
|
||||
* routing time (re-checked with `notFound()` for defence-in-depth). The slug
|
||||
* list is imported from the SERVER-safe `layout-groups` module so this server
|
||||
* component never pulls runtime values across a client boundary.
|
||||
*/
|
||||
|
||||
export const dynamicParams = false;
|
||||
|
||||
export function generateStaticParams() {
|
||||
return LAYOUT_GROUP_IDS.map((module) => ({ module }));
|
||||
}
|
||||
|
||||
interface PageProps {
|
||||
// Next.js 16 made route `params` an async Promise. We `await` it before use.
|
||||
params: Promise<{ module: string }>;
|
||||
}
|
||||
|
||||
export default async function SettingsLayoutModulePage({ params }: PageProps) {
|
||||
const { module } = await params;
|
||||
|
||||
if (!isLayoutGroupId(module)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const group = LAYOUT_GROUPS.find((entry) => entry.id === module);
|
||||
if (!group) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Heading lives in the shell (its own grid row spanning only the content
|
||||
// column). The title is the MODULE's title, resolved server-side from the
|
||||
// cookie locale (mirroring the onboarding pages) so the shell receives a
|
||||
// ready string — no client re-translation, so server HTML and the hydration
|
||||
// render carry the identical heading text.
|
||||
const locale = await resolveServerLocale();
|
||||
const { t } = getServerTranslator(locale);
|
||||
const Body = group.Body;
|
||||
const headingId = `settings-section-${module}-title`;
|
||||
|
||||
return (
|
||||
<SettingsShell
|
||||
active="layout"
|
||||
heading={{
|
||||
title: t(group.titleKey),
|
||||
subtitle: t(group.descriptionKey),
|
||||
headingId,
|
||||
topSlot: (
|
||||
<SettingsHubBackLink
|
||||
href="/settings/layout"
|
||||
labelKey="settings.sections.layout.backToHub"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
>
|
||||
<section aria-labelledby={headingId} className="space-y-6">
|
||||
<LayoutModuleGate moduleGate={group.moduleGate}>
|
||||
<Body />
|
||||
</LayoutModuleGate>
|
||||
</section>
|
||||
</SettingsShell>
|
||||
);
|
||||
}
|
||||
@@ -500,6 +500,10 @@ export interface PublicVersion {
|
||||
buildSha: string | null;
|
||||
builtAt: string | null;
|
||||
offlineGeoEnabled?: boolean;
|
||||
// Host of the online geo provider the runtime falls back to when the
|
||||
// offline tier is absent (host of IP_GEO_LOOKUP_URL, default ipwho.is).
|
||||
// Optional so legacy /api/version responses still satisfy the shape.
|
||||
geoProviderHost?: string;
|
||||
}
|
||||
|
||||
/** Shared `/api/version` reader — the system-status section + overview
|
||||
|
||||
@@ -102,6 +102,7 @@ export function GlitchtipSection() {
|
||||
spellCheck={false}
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
data-bwignore="true"
|
||||
disabled={updateSettings.isPending}
|
||||
/>
|
||||
</div>
|
||||
@@ -121,6 +122,7 @@ export function GlitchtipSection() {
|
||||
spellCheck={false}
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
data-bwignore="true"
|
||||
disabled={updateSettings.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Check,
|
||||
CheckCircle2,
|
||||
Copy,
|
||||
Download,
|
||||
Loader2,
|
||||
MapPin,
|
||||
@@ -94,6 +97,25 @@ export function LoginOverviewSection() {
|
||||
const AUTH_ACTION_LABELS = useAuthActionLabels();
|
||||
const AUTH_PROVIDER_LABELS = useAuthProviderLabels();
|
||||
|
||||
// v1.25.11 (#151) — long IPv6 addresses are truncated in the table cell so
|
||||
// they never force the wrapper into horizontal scroll; the full value is one
|
||||
// click away. Track the most-recently-copied row so the icon can flip to a
|
||||
// checkmark for transient feedback.
|
||||
const [copiedIp, setCopiedIp] = useState<string | null>(null);
|
||||
|
||||
async function copyIp(id: string, ip: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(ip);
|
||||
setCopiedIp(id);
|
||||
toast.success(t("admin.ipCopied"));
|
||||
window.setTimeout(() => {
|
||||
setCopiedIp((current) => (current === id ? null : current));
|
||||
}, 2_000);
|
||||
} catch {
|
||||
toast.error(t("admin.copyFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
// Build the query string once so it's reused by the data-query key, the
|
||||
// export download, and the next-/prev- buttons.
|
||||
const queryParams = useMemo(() => {
|
||||
@@ -464,7 +486,35 @@ export function LoginOverviewSection() {
|
||||
</span>
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 font-mono text-xs">
|
||||
{entry.ipAddress ?? "—"}
|
||||
{entry.ipAddress ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
copyIp(entry.id, entry.ipAddress as string)
|
||||
}
|
||||
title={entry.ipAddress}
|
||||
aria-label={t("admin.copyIp")}
|
||||
data-slot="login-overview-ip"
|
||||
className="hover:text-foreground focus-visible:ring-ring inline-flex max-w-[10rem] items-center gap-1 rounded font-mono focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
<span className="truncate">
|
||||
{entry.ipAddress}
|
||||
</span>
|
||||
{copiedIp === entry.id ? (
|
||||
<Check
|
||||
className="text-dracula-green h-3 w-3 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<Copy
|
||||
className="h-3 w-3 shrink-0 opacity-60"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs">
|
||||
{entry.location ?? "—"}
|
||||
|
||||
@@ -169,7 +169,9 @@ export function SystemStatusSection() {
|
||||
value={
|
||||
version.offlineGeoEnabled
|
||||
? t("admin.offlineGeoEnabled")
|
||||
: t("admin.offlineGeoFallback")
|
||||
: t("admin.offlineGeoFallback", {
|
||||
host: version.geoProviderHost ?? "ipwho.is",
|
||||
})
|
||||
}
|
||||
className={
|
||||
version.offlineGeoEnabled
|
||||
|
||||
@@ -106,7 +106,9 @@ export function SystemStatusSummary() {
|
||||
value={
|
||||
version.offlineGeoEnabled
|
||||
? t("admin.overview.snapshotOfflineGeoOn")
|
||||
: t("admin.overview.snapshotOfflineGeoOff")
|
||||
: t("admin.overview.snapshotOfflineGeoOff", {
|
||||
host: version.geoProviderHost ?? "ipwho.is",
|
||||
})
|
||||
}
|
||||
className={
|
||||
version.offlineGeoEnabled
|
||||
|
||||
@@ -105,6 +105,7 @@ export function UmamiSection() {
|
||||
spellCheck={false}
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
data-bwignore="true"
|
||||
disabled={updateSettings.isPending}
|
||||
/>
|
||||
</div>
|
||||
@@ -122,6 +123,7 @@ export function UmamiSection() {
|
||||
spellCheck={false}
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
data-bwignore="true"
|
||||
disabled={updateSettings.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -144,6 +144,7 @@ export function WebPushVapidSection() {
|
||||
spellCheck={false}
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
data-bwignore="true"
|
||||
disabled={updateSettings.isPending}
|
||||
/>
|
||||
</div>
|
||||
@@ -163,6 +164,7 @@ export function WebPushVapidSection() {
|
||||
spellCheck={false}
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
data-bwignore="true"
|
||||
disabled={updateSettings.isPending}
|
||||
/>
|
||||
</div>
|
||||
@@ -182,6 +184,7 @@ export function WebPushVapidSection() {
|
||||
spellCheck={false}
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
data-bwignore="true"
|
||||
disabled={updateSettings.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -47,7 +47,10 @@ export function CustomMetricList() {
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-3" data-slot="custom-metric-list">
|
||||
<section
|
||||
className="border-border space-y-3 border-t pt-6"
|
||||
data-slot="custom-metric-list"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-lg font-semibold tracking-tight">
|
||||
@@ -62,6 +65,7 @@ export function CustomMetricList() {
|
||||
size="sm"
|
||||
className="min-h-11 shrink-0 sm:min-h-9"
|
||||
onClick={() => setAddOpen(true)}
|
||||
aria-label={t("customMetrics.add")}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{t("customMetrics.add")}</span>
|
||||
@@ -86,7 +90,11 @@ export function CustomMetricList() {
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("customMetrics.emptyDescription")}
|
||||
</p>
|
||||
<Button size="sm" onClick={() => setAddOpen(true)}>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setAddOpen(true)}
|
||||
aria-label={t("customMetrics.add")}
|
||||
>
|
||||
{t("customMetrics.add")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
|
||||
@@ -110,7 +110,7 @@ export function DashboardHeader({
|
||||
data-testid="dashboard-customize-shortcut"
|
||||
>
|
||||
<Link
|
||||
href="/settings/dashboard"
|
||||
href="/settings/layout/dashboard"
|
||||
aria-label={t("dashboard.customizeDashboard")}
|
||||
title={t("dashboard.customizeDashboard")}
|
||||
>
|
||||
|
||||
@@ -401,7 +401,7 @@ export function IllnessView() {
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
>
|
||||
<Link
|
||||
href="/settings/illness"
|
||||
href="/settings/layout/illness"
|
||||
aria-label={t("illness.customize")}
|
||||
title={t("illness.customize")}
|
||||
>
|
||||
|
||||
@@ -49,7 +49,9 @@ describe("<InsightsEditMode> — detail-page manager retired (v1.15.20)", () =>
|
||||
it("links to the settings pill section instead", () => {
|
||||
const html = renderEditMode();
|
||||
expect(html).toContain('data-slot="insights-edit-manage-link"');
|
||||
expect(html).toContain('href="/settings/insights#insights-pill-order"');
|
||||
expect(html).toContain(
|
||||
'href="/settings/layout/insights#insights-pill-order"',
|
||||
);
|
||||
expect(html).toContain("Manage pills & detail pages in Settings");
|
||||
});
|
||||
|
||||
|
||||
@@ -784,7 +784,14 @@ export function VitalsDashboard({ batch, layout, className }: DashboardProps) {
|
||||
aria-label={
|
||||
isLoading ? t("insights.derived.vitals.loadingLabel") : undefined
|
||||
}
|
||||
className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"
|
||||
className={cn(
|
||||
"grid grid-cols-1 gap-3",
|
||||
// Multi-column only when the skeleton row or more than one tile
|
||||
// fills it; a lone tile spans the full width rather than sitting
|
||||
// in a one-third column with dead space beside it.
|
||||
(isLoading || vitalTiles.length > 1) &&
|
||||
"sm:grid-cols-2 lg:grid-cols-3",
|
||||
)}
|
||||
>
|
||||
{isLoading
|
||||
? Array.from({ length: 6 }).map((_, i) => (
|
||||
@@ -806,7 +813,12 @@ export function VitalsDashboard({ batch, layout, className }: DashboardProps) {
|
||||
/>
|
||||
<div
|
||||
data-slot="vitals-mobility-grid"
|
||||
className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"
|
||||
className={cn(
|
||||
"grid grid-cols-1 gap-3",
|
||||
// A lone mobility tile spans the full width rather than
|
||||
// orphaning a one-third column.
|
||||
mobilityTiles.length > 1 && "sm:grid-cols-2 lg:grid-cols-3",
|
||||
)}
|
||||
>
|
||||
{mobilityTiles.map((tile) => tile.node)}
|
||||
</div>
|
||||
|
||||
@@ -121,7 +121,7 @@ export function DeviceScoreTile({
|
||||
</CardAction>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<CardContent className="space-y-3 pb-1">
|
||||
{isLearning ? (
|
||||
<LearningGate
|
||||
compact
|
||||
|
||||
@@ -279,7 +279,7 @@ export function InsightsEditMode({
|
||||
the Vitals row duplicated it. Keep a quiet pointer instead. */}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<Link
|
||||
href="/settings/insights#insights-pill-order"
|
||||
href="/settings/layout/insights#insights-pill-order"
|
||||
data-slot="insights-edit-manage-link"
|
||||
className="hover:text-foreground focus-visible:ring-ring rounded underline underline-offset-2 focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
|
||||
@@ -882,7 +882,7 @@ function InsightsTabStripImpl({
|
||||
point. */}
|
||||
{onRegenerate && (
|
||||
<Link
|
||||
href="/settings/insights"
|
||||
href="/settings/layout/insights"
|
||||
aria-label={customizeLabel}
|
||||
title={customizeLabel}
|
||||
data-slot="insights-tab-strip-customize"
|
||||
|
||||
@@ -83,15 +83,21 @@ const STRENGTH_KEY: Record<CorrelationResult["strength"], string> = {
|
||||
function MoodCorrelationCard({
|
||||
kind,
|
||||
data,
|
||||
className,
|
||||
}: {
|
||||
kind: MetricKind;
|
||||
data: MoodMetricCorrelationData;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const hasResult = data.result != null && data.n >= 5;
|
||||
|
||||
return (
|
||||
<Card data-slot="mood-correlation-card" data-kind={kind}>
|
||||
<Card
|
||||
data-slot="mood-correlation-card"
|
||||
data-kind={kind}
|
||||
className={className}
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<CardTitle className="text-sm font-semibold">
|
||||
@@ -165,7 +171,15 @@ export function MoodCorrelationCards({
|
||||
<MoodCorrelationCard kind="steps" data={steps} />
|
||||
<MoodCorrelationCard kind="pulse" data={pulse} />
|
||||
<MoodCorrelationCard kind="weight" data={weight} />
|
||||
<MoodCorrelationCard kind="bloodPressure" data={bloodPressureSystolic} />
|
||||
{/* Five fixed cards: on the two-up sm grid the trailing card lands
|
||||
alone in its row, so it spans full width there; the three-up lg
|
||||
grid leaves it paired (row of 3 + row of 2) and it stays one
|
||||
column. */}
|
||||
<MoodCorrelationCard
|
||||
kind="bloodPressure"
|
||||
data={bloodPressureSystolic}
|
||||
className="sm:col-span-2 lg:col-span-1"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* v1.8.6 — narrative takeaway feed.
|
||||
@@ -69,7 +70,12 @@ export function MoodNarrativeFeed({ items }: { items: MoodNarrativeItem[] }) {
|
||||
// ladder. Lay them two-up on anything wider than a phone; a lone
|
||||
// takeaway still spans the row rather than orphaning half of it.
|
||||
<ul
|
||||
className="grid gap-2 sm:grid-cols-2"
|
||||
className={cn(
|
||||
"grid gap-2",
|
||||
// A lone takeaway spans the row rather than orphaning half of it;
|
||||
// two or more lay out two-up on anything wider than a phone.
|
||||
items.length > 1 && "sm:grid-cols-2",
|
||||
)}
|
||||
data-slot="mood-narrative-feed"
|
||||
aria-label={t("insights.mood.narrative.title")}
|
||||
>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Activity, Gauge, Moon, Repeat, Target, Waves } from "lucide-react";
|
||||
|
||||
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SectionHeading } from "@/components/insights/section-heading";
|
||||
import { DeviceScoreTile } from "@/components/insights/device-score-tile";
|
||||
import { DeviceScoreGridSkeleton } from "@/components/insights/device-score-tile-skeleton";
|
||||
@@ -111,7 +112,14 @@ export function SleepQualitySection({ enabled }: { enabled: boolean }) {
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-4",
|
||||
// Two-up only when more than one metric has data; a lone tile spans
|
||||
// the full row rather than leaving a half-width orphan.
|
||||
present.length > 1 && "sm:grid-cols-2",
|
||||
)}
|
||||
>
|
||||
{present.map((tile) => (
|
||||
<DeviceScoreTile
|
||||
key={tile.type}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { formatDate } from "@/lib/format";
|
||||
import { formatReferenceRange } from "@/lib/labs/reference-range";
|
||||
import { formatLabReading, formatLabValue } from "@/lib/labs/format-value";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { applyOrder, useModuleListPrefs } from "@/lib/module-list-prefs";
|
||||
|
||||
@@ -247,7 +248,14 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
|
||||
// the line-1 chip, the panel as the category badge. v1.18.10 (#3) — no delete
|
||||
// control on the tile; the trash icon lives only on the value detail view.
|
||||
return (
|
||||
<ul className="grid list-none gap-4 p-0 sm:grid-cols-2">
|
||||
<ul
|
||||
className={cn(
|
||||
"grid list-none gap-4 p-0",
|
||||
// A lone lab spans the full row rather than orphaning half of it;
|
||||
// two or more fall into the two-up grid.
|
||||
groups.length > 1 && "sm:grid-cols-2",
|
||||
)}
|
||||
>
|
||||
{truncated ? (
|
||||
<li className="sm:col-span-2">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
|
||||
@@ -377,7 +377,7 @@ export function VorsorgeSection({
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
>
|
||||
<Link
|
||||
href="/settings/vorsorge"
|
||||
href="/settings/layout/vorsorge"
|
||||
aria-label={t("measurementReminders.customize")}
|
||||
title={t("measurementReminders.customize")}
|
||||
>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { DateTimeField } from "@/components/ui/date-time-field";
|
||||
import { FieldGroup } from "@/components/ui/field-group";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
@@ -386,12 +387,9 @@ export function MeasurementForm({
|
||||
|
||||
return (
|
||||
<form id={formId} onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Label htmlFor="measurement-type" className="shrink-0">
|
||||
{t("measurements.type")}
|
||||
</Label>
|
||||
<FieldGroup htmlFor="measurement-type" label={t("measurements.type")}>
|
||||
<Select value={type} onValueChange={setType}>
|
||||
<SelectTrigger id="measurement-type" className="flex-1">
|
||||
<SelectTrigger id="measurement-type" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -402,7 +400,7 @@ export function MeasurementForm({
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
|
||||
{isBpMode ? (
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
@@ -463,16 +461,16 @@ export function MeasurementForm({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="value">
|
||||
{t("measurements.valueWithUnit", {
|
||||
unit: typeInfo
|
||||
? "unitKey" in typeInfo
|
||||
? t(typeInfo.unitKey)
|
||||
: typeInfo.unit
|
||||
: "",
|
||||
})}
|
||||
</Label>
|
||||
<FieldGroup
|
||||
htmlFor="value"
|
||||
label={t("measurements.valueWithUnit", {
|
||||
unit: typeInfo
|
||||
? "unitKey" in typeInfo
|
||||
? t(typeInfo.unitKey)
|
||||
: typeInfo.unit
|
||||
: "",
|
||||
})}
|
||||
>
|
||||
<Input
|
||||
id="value"
|
||||
type="number"
|
||||
@@ -490,14 +488,14 @@ export function MeasurementForm({
|
||||
aria-invalid={!!error || undefined}
|
||||
aria-describedby={errorDescriptor}
|
||||
/>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
)}
|
||||
|
||||
{isGlucoseMode && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="glucose-context">
|
||||
{t("measurements.glucoseContext")}
|
||||
</Label>
|
||||
<FieldGroup
|
||||
htmlFor="glucose-context"
|
||||
label={t("measurements.glucoseContext")}
|
||||
>
|
||||
<Select
|
||||
value={glucoseContext}
|
||||
onValueChange={(v) => setGlucoseContext(v as GlucoseContextValue)}
|
||||
@@ -513,11 +511,17 @@ export function MeasurementForm({
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="measuredAt">{t("measurements.timestamp")}</Label>
|
||||
<FieldGroup
|
||||
htmlFor="measuredAt"
|
||||
label={t("measurements.timestamp")}
|
||||
// v1.17.1 — surface that the timestamp is editable. The picker
|
||||
// already defaults to now and accepts any earlier instant; many
|
||||
// users never notice they can backdate a reading.
|
||||
hint={t("measurements.timestampBackdateHint")}
|
||||
>
|
||||
<DateTimeField
|
||||
id="measuredAt"
|
||||
value={measuredAt}
|
||||
@@ -531,26 +535,24 @@ export function MeasurementForm({
|
||||
aria-invalid={!!error || undefined}
|
||||
aria-describedby={errorDescriptor}
|
||||
/>
|
||||
{/* v1.17.1 — surface that the timestamp is editable. The picker
|
||||
already defaults to now and accepts any earlier instant; many
|
||||
users never notice they can backdate a reading. */}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("measurements.timestampBackdateHint")}
|
||||
</p>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label htmlFor="notes">
|
||||
<FieldGroup
|
||||
htmlFor="notes"
|
||||
label={
|
||||
<>
|
||||
{t("measurements.notes")}{" "}
|
||||
<span className="text-muted-foreground font-normal">
|
||||
({t("common.optional")})
|
||||
</span>
|
||||
</Label>
|
||||
</>
|
||||
}
|
||||
labelAccessory={
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{notes.length}/{MAX_COMMENT_LENGTH}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Input
|
||||
id="notes"
|
||||
value={notes}
|
||||
@@ -560,7 +562,7 @@ export function MeasurementForm({
|
||||
enterKeyHint="done"
|
||||
autoCapitalize="sentences"
|
||||
/>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
|
||||
@@ -283,7 +283,7 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
|
||||
expect(html).not.toContain("Dose history");
|
||||
});
|
||||
|
||||
it("shows drug name + current dose on line 1 ('Mounjaro 7.5 mg')", () => {
|
||||
it("shows drug name on line 1 and the dose on its own line below", () => {
|
||||
const client = makeClient();
|
||||
seedCompliance(client, med7p5.id);
|
||||
seedGlp1Details(client, med7p5.id, {});
|
||||
@@ -299,10 +299,10 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
|
||||
|
||||
expect(html).toContain("Mounjaro");
|
||||
expect(html).toContain("7.5 mg");
|
||||
// v1.4.28 FB-G1 — the GLP-1 row drops the middle-dot separator and
|
||||
// surfaces `{name} {dose}` together on line 1 via the shared
|
||||
// `<MedicationCardHeader>` primitive. The dot separator is gone.
|
||||
expect(html).toContain("Mounjaro 7.5 mg");
|
||||
// FB-G1 — the GLP-1 row drops the middle-dot separator; the dose now
|
||||
// rides its OWN muted line under the name (the shared
|
||||
// `<MedicationCardHeader>` primitive), localised via `formatDose`.
|
||||
expect(html).toContain('data-slot="medication-card-header-dose"');
|
||||
});
|
||||
|
||||
it("shows last + next injection labels with localised weekday", () => {
|
||||
|
||||
@@ -9,8 +9,9 @@ import { MedicationCardHeader } from "@/components/medications/medication-card-h
|
||||
* v1.4.28 — narrow-viewport contract for the shared medication-list
|
||||
* row primitive (D-H6).
|
||||
*
|
||||
* FB-G1 calls for a two-line shape: `{name} {dose}` on line 1 and the
|
||||
* category badge on line 2. State badges (without-notification, paused,
|
||||
* FB-G1 calls for a stacked shape: `{name}` on line 1, the localised
|
||||
* `{dose}` on its own muted line below it (omitted when empty), and the
|
||||
* category badge under that. State badges (without-notification, paused,
|
||||
* inactive) used to share line 2 via `flex flex-wrap`; on a 320 px
|
||||
* viewport that pushed the row to three lines for ~20 % of configured
|
||||
* drugs. State badges now ride their own row below the category badge
|
||||
@@ -27,7 +28,7 @@ function render(node: React.ReactNode) {
|
||||
}
|
||||
|
||||
describe("<MedicationCardHeader>", () => {
|
||||
it("paints `{name} {dose}` on the title row", () => {
|
||||
it("paints the name on the title row and the dose on its own muted line", () => {
|
||||
const html = render(
|
||||
<MedicationCardHeader
|
||||
name="Ramipril"
|
||||
@@ -35,8 +36,29 @@ describe("<MedicationCardHeader>", () => {
|
||||
categoryLabel="Blood Pressure"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Ramipril 5 mg");
|
||||
// Name + dose are now separate elements — the dose rides its own
|
||||
// `data-slot` line directly under the title, not glued to the name.
|
||||
expect(html).toContain("Ramipril");
|
||||
expect(html).toContain('data-slot="medication-card-header-dose"');
|
||||
expect(html).toContain("5 mg");
|
||||
expect(html).toContain("Blood Pressure");
|
||||
// The dose line paints between the name and the category badge.
|
||||
const nameIdx = html.indexOf("Ramipril");
|
||||
const doseIdx = html.indexOf('data-slot="medication-card-header-dose"');
|
||||
const categoryIdx = html.indexOf("Blood Pressure");
|
||||
expect(nameIdx).toBeLessThan(doseIdx);
|
||||
expect(doseIdx).toBeLessThan(categoryIdx);
|
||||
});
|
||||
|
||||
it("omits the dose line entirely when the dose is empty", () => {
|
||||
const html = render(
|
||||
<MedicationCardHeader
|
||||
name="Vorsorge"
|
||||
dose=""
|
||||
categoryLabel="Screening"
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="medication-card-header-dose"');
|
||||
});
|
||||
|
||||
it("does not paint the state-badges row when no badges are supplied", () => {
|
||||
@@ -100,8 +122,9 @@ describe("<MedicationCardHeader>", () => {
|
||||
expect(html).toContain('data-slot="medication-card-header-link"');
|
||||
expect(html).toContain('href="/medications/med-1"');
|
||||
expect(html).toContain('aria-label="Open medication detail page"');
|
||||
// The title still rides inside the link region.
|
||||
expect(html).toContain("Ramipril 5 mg");
|
||||
// The name + dose still ride inside the link region.
|
||||
expect(html).toContain("Ramipril");
|
||||
expect(html).toContain("5 mg");
|
||||
});
|
||||
|
||||
it("falls back to a non-navigating div when href is omitted", () => {
|
||||
|
||||
@@ -80,6 +80,7 @@ import { queryKeys } from "@/lib/query-keys";
|
||||
import { formatUnitCount } from "@/components/medications/units-per-dose";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { formatDose } from "@/lib/medications/format-dose";
|
||||
import type { MedicationPayload } from "@/components/medications/wizard/wizard-payload";
|
||||
import type { ComplianceDisplay } from "@/lib/analytics/compliance";
|
||||
|
||||
@@ -392,7 +393,7 @@ export function MedicationDetailTabs({
|
||||
page" and misled). `?edit=1` still opens the wizard directly. */}
|
||||
<MedicationDetailSummary
|
||||
name={medication.name}
|
||||
dose={medication.dose}
|
||||
dose={formatDose(medication.dose, t)}
|
||||
active={medication.active}
|
||||
endsOn={medication.endsOn}
|
||||
payload={payload}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
import { formatDateTime, formatTime } from "@/lib/format";
|
||||
import { getMedicationCategoryLabel } from "@/lib/medications/category-label";
|
||||
import { formatDose } from "@/lib/medications/format-dose";
|
||||
import { type InjectionSiteKey } from "@/lib/medications/injection-sites";
|
||||
import { LogInjectionSiteDialog } from "@/components/medications/log-injection-site-dialog";
|
||||
import { useGlobalExcludedInjectionSites } from "@/lib/medications/use-injection-site-prefs";
|
||||
@@ -467,7 +468,7 @@ export function Glp1MedicationCard({
|
||||
{schedule?.dose && (
|
||||
<span className="text-dose-accent hidden font-medium sm:inline">
|
||||
{" "}
|
||||
— {schedule.dose}
|
||||
— {formatDose(schedule.dose, t)}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
@@ -483,7 +484,7 @@ export function Glp1MedicationCard({
|
||||
return (
|
||||
<MedicationCardBody
|
||||
name={medication.name}
|
||||
dose={medication.dose}
|
||||
dose={formatDose(medication.dose, t)}
|
||||
categoryLabel={categoryLabel}
|
||||
active={medication.active}
|
||||
href={`/medications/${medication.id}`}
|
||||
|
||||
@@ -39,6 +39,7 @@ import { DateTimeField } from "@/components/ui/date-time-field";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { formatDose } from "@/lib/medications/format-dose";
|
||||
import { runLogIntake } from "@/components/medications/use-medication-intake";
|
||||
|
||||
interface LogIntakeSchedule {
|
||||
@@ -243,7 +244,7 @@ export function LogIntakeDialog({
|
||||
>
|
||||
{medications.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.dose ? `${m.name} — ${m.dose}` : m.name}
|
||||
{m.dose ? `${m.name} — ${formatDose(m.dose, t)}` : m.name}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
|
||||
@@ -14,8 +14,9 @@ import { CardHeader, CardTitle } from "@/components/ui/card";
|
||||
* route their title + dose + drug class through this primitive so the
|
||||
* surface stays one consistent shape:
|
||||
*
|
||||
* Line 1: `{name} {dose}` — bold, `text-lg`
|
||||
* Line 2: `{categoryLabel}` outline badge + optional state badges
|
||||
* Line 1: `{name}` — bold, `text-lg`
|
||||
* Line 2: `{dose}` — muted, rendered only when non-empty
|
||||
* Line 3: `{categoryLabel}` outline badge + optional state badges
|
||||
*
|
||||
* The trailing `actions` slot carries the overflow kebab on the right of
|
||||
* the row. State badges (without-notification, paused-since, inactive)
|
||||
@@ -78,11 +79,22 @@ export function MedicationCardHeader({
|
||||
const body = (
|
||||
<>
|
||||
<CardTitle className="flex flex-wrap items-center gap-x-2 gap-y-1 text-lg">
|
||||
<span>
|
||||
{name} {dose}
|
||||
</span>
|
||||
<span>{name}</span>
|
||||
{nameChip}
|
||||
</CardTitle>
|
||||
{/* The dose sits on its OWN muted line directly under the name, above
|
||||
the category badge. Rendered only when a non-empty dose string is
|
||||
supplied — Vorsorge / lab-list rows pass `dose=""` and get no empty
|
||||
line. The unit is already localised by `formatDose` at the call
|
||||
site (the raw `Medication.dose` carries the English unit key). */}
|
||||
{dose ? (
|
||||
<p
|
||||
data-slot="medication-card-header-dose"
|
||||
className="text-muted-foreground text-sm"
|
||||
>
|
||||
{dose}
|
||||
</p>
|
||||
) : null}
|
||||
{/* D-H6 — state badges (without-notification, paused-since,
|
||||
inactive) used to share line 2 with the category badge
|
||||
via `flex flex-wrap`. At 320 px any state badge pushed
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useWeekdayLabel } from "@/components/medications/card-parts/medication-
|
||||
import { formatTimeWindowRange } from "@/lib/time-window-format";
|
||||
import { formatDateTime, formatTime } from "@/lib/format";
|
||||
import { getMedicationCategoryLabel } from "@/lib/medications/category-label";
|
||||
import { formatDose } from "@/lib/medications/format-dose";
|
||||
import {
|
||||
reduceCurrentWindowStatus,
|
||||
toZonedDate,
|
||||
@@ -451,7 +452,7 @@ export function MedicationCard({
|
||||
{s.dose && (
|
||||
<span className="text-dose-accent hidden font-medium sm:inline">
|
||||
{" "}
|
||||
— {s.dose}
|
||||
— {formatDose(s.dose, t)}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
@@ -462,7 +463,7 @@ export function MedicationCard({
|
||||
return (
|
||||
<MedicationCardBody
|
||||
name={medication.name}
|
||||
dose={medication.dose}
|
||||
dose={formatDose(medication.dose, t)}
|
||||
categoryLabel={categoryLabel}
|
||||
active={medication.active}
|
||||
href={`/medications/${medication.id}`}
|
||||
|
||||
@@ -24,6 +24,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { reorderById } from "@/lib/insights-layout-reorder";
|
||||
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { formatDose } from "@/lib/medications/format-dose";
|
||||
import { runSaveMedicationListOrder } from "@/lib/queries/use-medication-list-layout";
|
||||
|
||||
/**
|
||||
@@ -321,6 +322,7 @@ function SortableMedicationRow({
|
||||
labels,
|
||||
onMove,
|
||||
}: SortableMedicationRowProps) {
|
||||
const { t } = useTranslations();
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
@@ -363,7 +365,7 @@ function SortableMedicationRow({
|
||||
{medication.name}
|
||||
</span>
|
||||
<span className="text-muted-foreground block truncate text-xs">
|
||||
{medication.dose}
|
||||
{formatDose(medication.dose, t)}
|
||||
{!medication.active && <> · {labels.inactive}</>}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
import { formatTime } from "@/lib/format";
|
||||
import { formatTimeWindowRange } from "@/lib/time-window-format";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { formatDose } from "@/lib/medications/format-dose";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
@@ -649,7 +650,7 @@ function MedicationTableRowItem({
|
||||
{medication.name}
|
||||
</span>
|
||||
<span className="text-muted-foreground block text-xs">
|
||||
{medication.dose}
|
||||
{formatDose(medication.dose, t)}
|
||||
{!medication.active && <> · {t("common.inactive")}</>}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { formatDose } from "@/lib/medications/format-dose";
|
||||
import { formatTimeWindowRange } from "@/lib/time-window-format";
|
||||
import {
|
||||
runTakeAllDue,
|
||||
@@ -88,7 +89,10 @@ export function TakeAllDueDialog({
|
||||
<span className="min-w-0 truncate">
|
||||
<span className="font-medium">{med.name}</span>
|
||||
{med.dose ? (
|
||||
<span className="text-muted-foreground"> — {med.dose}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
— {formatDose(med.dose, t)}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{med.window ? (
|
||||
|
||||
@@ -22,30 +22,11 @@ import {
|
||||
} from "@/lib/medications/injection-sites";
|
||||
import type { MedicationDeliveryForm } from "@/lib/validations/medication";
|
||||
import { unitsPerDoseOptionsFor } from "@/components/medications/units-per-dose";
|
||||
import { DOSE_UNIT_KEYS } from "@/lib/medications/dose-units";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import type { StepProps } from "./step1-name";
|
||||
|
||||
/**
|
||||
* Dose unit choices, ordered so mg / ml / IE / µg lead and the
|
||||
* device-form units (Tablette, Hub, Sprühstoß, …) follow. Mirrors the
|
||||
* v1.5.3 list with the maintainer-requested order tightening.
|
||||
*/
|
||||
const DOSE_UNIT_KEYS = [
|
||||
"mg",
|
||||
"ml",
|
||||
"iu",
|
||||
"mcg",
|
||||
"g",
|
||||
"tablets",
|
||||
"capsules",
|
||||
"drops",
|
||||
"puffs",
|
||||
"sprays",
|
||||
"pieces",
|
||||
"other",
|
||||
] as const;
|
||||
|
||||
const DELIVERY_FORM_KEYS: readonly MedicationDeliveryForm[] = [
|
||||
"ORAL",
|
||||
"INJECTION",
|
||||
|
||||
@@ -36,9 +36,9 @@ export function InstrumentCard({
|
||||
|
||||
return (
|
||||
<Card className="h-full gap-3" data-slot="instrument-card">
|
||||
<CardContent className="flex h-full flex-col gap-4 p-4">
|
||||
<CardContent className="flex h-full flex-col space-y-3.5 p-4">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-medium">
|
||||
<span className="text-lg leading-none font-semibold">
|
||||
{t(`mentalHealth.instrument.${key}`)}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { DateTimeField } from "@/components/ui/date-time-field";
|
||||
import { FieldGroup } from "@/components/ui/field-group";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
ListChecks,
|
||||
@@ -473,17 +474,20 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
|
||||
</div>
|
||||
|
||||
{/* v1.8.5 (C1) — free-text note. */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label htmlFor="mood-note">
|
||||
<FieldGroup
|
||||
htmlFor="mood-note"
|
||||
label={
|
||||
<>
|
||||
{t("mood.note")}{" "}
|
||||
<span className="text-muted-foreground font-normal">
|
||||
({t("common.optional")})
|
||||
</span>
|
||||
</Label>
|
||||
{/* v1.11.5 — character counter so the `maxLength` cap no
|
||||
</>
|
||||
}
|
||||
labelAccessory={
|
||||
/* v1.11.5 — character counter so the `maxLength` cap no
|
||||
longer truncates silently. Turns destructive (warns) as the
|
||||
input approaches the limit. */}
|
||||
input approaches the limit. */
|
||||
<span
|
||||
data-testid="mood-note-counter"
|
||||
className={`text-xs tabular-nums ${
|
||||
@@ -498,7 +502,8 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
|
||||
max: String(NOTE_MAX_LENGTH),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Textarea
|
||||
id="mood-note"
|
||||
value={note}
|
||||
@@ -507,10 +512,9 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
|
||||
maxLength={NOTE_MAX_LENGTH}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="mood-logged-at">{t("mood.timestamp")}</Label>
|
||||
<FieldGroup htmlFor="mood-logged-at" label={t("mood.timestamp")}>
|
||||
<DateTimeField
|
||||
id="mood-logged-at"
|
||||
value={moodLoggedAt}
|
||||
@@ -522,7 +526,7 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
|
||||
aria-invalid={!!error || undefined}
|
||||
aria-describedby={errorDescriptor}
|
||||
/>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</SheetSection>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -9,8 +9,8 @@ import { Loader2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DateField } from "@/components/ui/date-field";
|
||||
import { FieldGroup } from "@/components/ui/field-group";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -170,10 +170,11 @@ export function BaselineForm() {
|
||||
<fieldset className="bg-card border-border space-y-5 rounded-xl border p-5">
|
||||
<legend className="sr-only">{t("onboarding.baseline.title")}</legend>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ob-baseline-display-name">
|
||||
{t("onboarding.baseline.displayNameLabel")}
|
||||
</Label>
|
||||
<FieldGroup
|
||||
htmlFor="ob-baseline-display-name"
|
||||
label={t("onboarding.baseline.displayNameLabel")}
|
||||
hint={t("onboarding.baseline.displayNameHint")}
|
||||
>
|
||||
<Input
|
||||
id="ob-baseline-display-name"
|
||||
value={form.displayName}
|
||||
@@ -182,16 +183,13 @@ export function BaselineForm() {
|
||||
maxLength={50}
|
||||
placeholder={t("onboarding.baseline.displayNamePlaceholder")}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("onboarding.baseline.displayNameHint")}
|
||||
</p>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ob-baseline-height">
|
||||
{t("onboarding.baseline.heightLabel")}
|
||||
</Label>
|
||||
<FieldGroup
|
||||
htmlFor="ob-baseline-height"
|
||||
label={t("onboarding.baseline.heightLabel")}
|
||||
>
|
||||
<Input
|
||||
id="ob-baseline-height"
|
||||
type="number"
|
||||
@@ -204,11 +202,11 @@ export function BaselineForm() {
|
||||
step={0.1}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ob-baseline-gender">
|
||||
{t("onboarding.baseline.genderLabel")}
|
||||
</Label>
|
||||
</FieldGroup>
|
||||
<FieldGroup
|
||||
htmlFor="ob-baseline-gender"
|
||||
label={t("onboarding.baseline.genderLabel")}
|
||||
>
|
||||
<Select
|
||||
// The design system's Radix Select uses an empty-string
|
||||
// sentinel to mean "no selection"; map back and forth so
|
||||
@@ -235,13 +233,14 @@ export function BaselineForm() {
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ob-baseline-dob">
|
||||
{t("onboarding.baseline.dateOfBirthLabel")}
|
||||
</Label>
|
||||
<FieldGroup
|
||||
htmlFor="ob-baseline-dob"
|
||||
label={t("onboarding.baseline.dateOfBirthLabel")}
|
||||
hint={t("onboarding.baseline.dateOfBirthHint")}
|
||||
>
|
||||
<DateField
|
||||
id="ob-baseline-dob"
|
||||
value={form.dateOfBirth}
|
||||
@@ -249,10 +248,7 @@ export function BaselineForm() {
|
||||
max={new Date().toISOString().slice(0, 10)}
|
||||
autoComplete="bday"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("onboarding.baseline.dateOfBirthHint")}
|
||||
</p>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</fieldset>
|
||||
|
||||
<AnamnesisCard
|
||||
|
||||
@@ -53,14 +53,17 @@ describe("<AdvancedSection> danger-zone shaping (v1.4.43 M3 + L5)", () => {
|
||||
it("L5: the data-reset card title is neutral foreground (no destructive red)", () => {
|
||||
const html = render();
|
||||
// The reset card's title used to ship with `text-destructive`; the
|
||||
// v1.4.43 fix pins it on `text-foreground`. We don't care if other
|
||||
// strings in the section happen to use `text-destructive` (the CTA
|
||||
// button does), only that the specific card title slot does NOT.
|
||||
// v1.4.43 fix neutralised it. The header now routes through the
|
||||
// canonical `<SettingsCardHeader>`, whose `<h2>` carries no colour
|
||||
// class at all and so inherits the neutral foreground. We don't care
|
||||
// if other strings in the section happen to use `text-destructive`
|
||||
// (the CTA button does), only that the specific card title slot does
|
||||
// NOT paint red.
|
||||
const cardMatch = html.match(
|
||||
/data-slot="settings-data-reset-card"[\s\S]*?<\/h2>/,
|
||||
);
|
||||
expect(cardMatch).not.toBeNull();
|
||||
expect(cardMatch![0]).toContain("text-foreground");
|
||||
expect(cardMatch![0]).toContain("text-lg font-semibold");
|
||||
expect(cardMatch![0]).not.toContain("text-destructive");
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* v1.25.11 (#148) — the shared Appearance group registry.
|
||||
*
|
||||
* `layout-groups` is the single source of truth consumed by BOTH the client
|
||||
* hub (`layout-section.tsx`) and the server subpage route
|
||||
* (`app/settings/layout/[module]/page.tsx`, via `LAYOUT_GROUP_IDS` in
|
||||
* `generateStaticParams()`). These tests pin the param → section mapping: the
|
||||
* id list (the statically-generated subpage params), the guard, the per-module
|
||||
* gate expectations, and that each group resolves a distinct section body.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Stub the section components at the import boundary — each has its own test;
|
||||
// here we only assert the registry's metadata + identity mapping, and we keep
|
||||
// the heavy section trees out of the unit import graph.
|
||||
vi.mock("../dashboard-section", () => ({ DashboardSection: () => null }));
|
||||
vi.mock("../insights-section", () => ({ InsightsSection: () => null }));
|
||||
vi.mock("../medications-section", () => ({ MedicationsSection: () => null }));
|
||||
vi.mock("../mood-section", () => ({ MoodSection: () => null }));
|
||||
vi.mock("../labs-section", () => ({ LabsSection: () => null }));
|
||||
vi.mock("../illness-section", () => ({ IllnessSection: () => null }));
|
||||
vi.mock("../vorsorge-section", () => ({ VorsorgeSection: () => null }));
|
||||
|
||||
import {
|
||||
LAYOUT_GROUPS,
|
||||
LAYOUT_GROUP_IDS,
|
||||
isLayoutGroupId,
|
||||
} from "../layout-groups";
|
||||
|
||||
describe("layout-groups registry", () => {
|
||||
it("exposes the seven Appearance modules as statically-generated subpage params", () => {
|
||||
expect(LAYOUT_GROUP_IDS).toEqual([
|
||||
"dashboard",
|
||||
"insights",
|
||||
"medications",
|
||||
"mood",
|
||||
"labs",
|
||||
"illness",
|
||||
"vorsorge",
|
||||
]);
|
||||
// generateStaticParams() shape.
|
||||
expect(LAYOUT_GROUP_IDS.map((module) => ({ module }))).toEqual([
|
||||
{ module: "dashboard" },
|
||||
{ module: "insights" },
|
||||
{ module: "medications" },
|
||||
{ module: "mood" },
|
||||
{ module: "labs" },
|
||||
{ module: "illness" },
|
||||
{ module: "vorsorge" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("guards unknown segments", () => {
|
||||
expect(isLayoutGroupId("medications")).toBe(true);
|
||||
expect(isLayoutGroupId("vorsorge")).toBe(true);
|
||||
expect(isLayoutGroupId("nope")).toBe(false);
|
||||
expect(isLayoutGroupId("")).toBe(false);
|
||||
});
|
||||
|
||||
it("gates the toggleable tracking modules and leaves the rest always-on", () => {
|
||||
const gate = (id: string) =>
|
||||
LAYOUT_GROUPS.find((group) => group.id === id)?.moduleGate;
|
||||
expect(gate("medications")).toBe("medications");
|
||||
expect(gate("mood")).toBe("mood");
|
||||
expect(gate("labs")).toBe("labs");
|
||||
expect(gate("illness")).toBe("illness");
|
||||
// dashboard / insights / vorsorge are never gated.
|
||||
expect(gate("dashboard")).toBeUndefined();
|
||||
expect(gate("insights")).toBeUndefined();
|
||||
expect(gate("vorsorge")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("maps every id to a distinct section body and the canonical i18n keys", () => {
|
||||
const bodies = new Set(LAYOUT_GROUPS.map((group) => group.Body));
|
||||
expect(bodies.size).toBe(LAYOUT_GROUPS.length);
|
||||
for (const group of LAYOUT_GROUPS) {
|
||||
expect(group.titleKey).toBe(`settings.sections.layout.${group.id}.title`);
|
||||
expect(group.descriptionKey).toBe(
|
||||
`settings.sections.layout.${group.id}.description`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,14 @@
|
||||
/**
|
||||
* v1.25.7 — the "Appearance" home (slug stays `layout`).
|
||||
* v1.25.11 (#148) — the "Appearance" hub (slug stays `layout`).
|
||||
*
|
||||
* The hub stopped being a link list: it now composes the dashboard, insights,
|
||||
* and every tracking module's settings inline as stacked, anchored sections.
|
||||
* Each module section keeps its module-gate (fail-open `!== false`), so a
|
||||
* disabled module's section does not render. These tests pin the composition
|
||||
* contract — anchors, headings, and gating — and mock the child section
|
||||
* components (each has its own dedicated test) so the render stays a pure
|
||||
* composition smoke.
|
||||
* The hub is a navigable index: it lists each module as a clickable row
|
||||
* (`<a href="/settings/layout/<id>">` with the localized title + description +
|
||||
* a chevron) and links to the module's own subpage. NOTHING is stacked inline.
|
||||
* These tests pin the hub contract — the rows link to the right subpages, the
|
||||
* module gate hides a disabled module's row, the never-gated rows always show,
|
||||
* and the titles resolve through i18n. The child section components are NOT
|
||||
* rendered by the hub, so they need no mocking here (each has its own test and
|
||||
* its own subpage).
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
@@ -23,28 +24,12 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// Each composed surface has its own test; stub them at the import boundary so
|
||||
// this test exercises only the LayoutSection composition (anchors + gating).
|
||||
vi.mock("@/components/settings/dashboard-section", () => ({
|
||||
DashboardSection: () => <div data-testid="body-dashboard" />,
|
||||
}));
|
||||
vi.mock("@/components/settings/insights-section", () => ({
|
||||
InsightsSection: () => <div data-testid="body-insights" />,
|
||||
}));
|
||||
vi.mock("@/components/settings/medications-section", () => ({
|
||||
MedicationsSection: () => <div data-testid="body-medications" />,
|
||||
}));
|
||||
vi.mock("@/components/settings/mood-section", () => ({
|
||||
MoodSection: () => <div data-testid="body-mood" />,
|
||||
}));
|
||||
vi.mock("@/components/settings/labs-section", () => ({
|
||||
LabsSection: () => <div data-testid="body-labs" />,
|
||||
}));
|
||||
vi.mock("@/components/settings/illness-section", () => ({
|
||||
IllnessSection: () => <div data-testid="body-illness" />,
|
||||
}));
|
||||
vi.mock("@/components/settings/vorsorge-section", () => ({
|
||||
VorsorgeSection: () => <div data-testid="body-vorsorge" />,
|
||||
// The hub renders post-mount fail-open gating via `useMounted()`. Under SSR
|
||||
// (`renderToStaticMarkup`) `useMounted()` returns `false`, which would render
|
||||
// EVERY row regardless of the gate map. Force it to the hydrated (`true`)
|
||||
// snapshot so these tests exercise the real module filter.
|
||||
vi.mock("@/hooks/use-mounted", () => ({
|
||||
useMounted: () => true,
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
@@ -59,8 +44,8 @@ function render(modules?: Record<string, boolean>, locale: "en" | "de" = "en") {
|
||||
);
|
||||
}
|
||||
|
||||
describe("<LayoutSection>", () => {
|
||||
it("stacks every surface inline with a stable anchor id (fail-open default)", () => {
|
||||
describe("<LayoutSection> hub", () => {
|
||||
it("lists every module as a clickable row linking to its subpage", () => {
|
||||
const html = render(undefined);
|
||||
for (const id of [
|
||||
"dashboard",
|
||||
@@ -71,45 +56,40 @@ describe("<LayoutSection>", () => {
|
||||
"illness",
|
||||
"vorsorge",
|
||||
]) {
|
||||
expect(html).toContain(`id="${id}"`);
|
||||
expect(html).toContain(`data-testid="body-${id}"`);
|
||||
expect(html).toContain(`href="/settings/layout/${id}"`);
|
||||
}
|
||||
// It is no longer a link hub — no `/settings/*` cards.
|
||||
expect(html).not.toContain('href="/settings/');
|
||||
});
|
||||
|
||||
it("hides a module's section when that module is disabled", () => {
|
||||
it("hides a module's row when that module is disabled", () => {
|
||||
const html = render({ medications: false, labs: false });
|
||||
expect(html).not.toContain('data-testid="body-medications"');
|
||||
expect(html).not.toContain('id="medications"');
|
||||
expect(html).not.toContain('data-testid="body-labs"');
|
||||
expect(html).not.toContain('id="labs"');
|
||||
expect(html).not.toContain('href="/settings/layout/medications"');
|
||||
expect(html).not.toContain('href="/settings/layout/labs"');
|
||||
// Other modules unaffected; Vorsorge is never gated.
|
||||
expect(html).toContain('data-testid="body-mood"');
|
||||
expect(html).toContain('data-testid="body-vorsorge"');
|
||||
expect(html).toContain('href="/settings/layout/mood"');
|
||||
expect(html).toContain('href="/settings/layout/vorsorge"');
|
||||
});
|
||||
|
||||
it("always renders dashboard, insights, and vorsorge (no module gate)", () => {
|
||||
it("always lists dashboard, insights, and vorsorge (no module gate)", () => {
|
||||
const html = render({
|
||||
medications: false,
|
||||
mood: false,
|
||||
labs: false,
|
||||
illness: false,
|
||||
});
|
||||
expect(html).toContain('data-testid="body-dashboard"');
|
||||
expect(html).toContain('data-testid="body-insights"');
|
||||
expect(html).toContain('data-testid="body-vorsorge"');
|
||||
expect(html).not.toContain('data-testid="body-mood"');
|
||||
expect(html).toContain('href="/settings/layout/dashboard"');
|
||||
expect(html).toContain('href="/settings/layout/insights"');
|
||||
expect(html).toContain('href="/settings/layout/vorsorge"');
|
||||
expect(html).not.toContain('href="/settings/layout/mood"');
|
||||
});
|
||||
|
||||
it("resolves its section headings via i18n (no raw keys leak)", () => {
|
||||
it("resolves its row titles via i18n (no raw keys leak)", () => {
|
||||
const html = render();
|
||||
expect(html).toContain("Medications");
|
||||
expect(html).toContain("Labs");
|
||||
expect(html).not.toContain("settings.sections.layout.");
|
||||
});
|
||||
|
||||
it("resolves the headings in German too", () => {
|
||||
it("resolves the titles in German too", () => {
|
||||
const html = render(undefined, "de");
|
||||
expect(html).toContain("Medikamente");
|
||||
expect(html).toContain("Labor");
|
||||
|
||||
@@ -35,6 +35,14 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ isAuthenticated: true }),
|
||||
}));
|
||||
|
||||
// The card gates its auth/loading branches behind `useMounted()` so SSR and
|
||||
// the first client paint render identically (`null`) and avoid a React #418
|
||||
// hydration mismatch. These tests assert the POST-hydration content tree, so
|
||||
// pin the probe to `true` — the state the card reaches once hydrated.
|
||||
vi.mock("@/hooks/use-mounted", () => ({
|
||||
useMounted: () => true,
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { NotificationStatusCard } from "../notification-status-card";
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Settings card-header drift guard.
|
||||
*
|
||||
* Every Settings card paints the same header shape — a muted icon, an
|
||||
* `<h2 class="text-lg font-semibold">` title, and an optional muted
|
||||
* description. The canonical primitive `SettingsCardHeader`
|
||||
* (`_card-header.tsx`) owns that shape; routing every card header through
|
||||
* it keeps the typography, the icon sizing, and the description size in
|
||||
* one place instead of drifting across hand-rolled `flex … <h2 text-lg`
|
||||
* permutations (the audit found five distinct variants once).
|
||||
*
|
||||
* This test globs every `*-section.tsx` / `*-card.tsx` under
|
||||
* `src/components/settings/` and flags any file that still hand-rolls a
|
||||
* top-level card header — a `<h2>` tag whose className carries `text-lg`.
|
||||
* The primitive itself is excluded (it OWNS the `<h2 text-lg>`), and a
|
||||
* short, explicitly-documented allowlist covers the handful of headers
|
||||
* that legitimately cannot use the primitive yet.
|
||||
*
|
||||
* Mirrors the call-site coverage guards under `src/__tests__/`: walk the
|
||||
* tree, match a mechanical pattern, assert the offending set is empty.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { join, relative } from "node:path";
|
||||
|
||||
const ROOT = join(__dirname, "../../../..");
|
||||
const SETTINGS_DIR = join(ROOT, "src/components/settings");
|
||||
|
||||
// Files allowed to retain a hand-rolled `<h2 text-lg>` header.
|
||||
const ALLOWLIST = new Set<string>([
|
||||
// allowed: collapsible-toggle header, button-wrapped, typography already canonical
|
||||
"security-activity-card.tsx",
|
||||
// allowed: collapsible-toggle header, button-wrapped, typography already canonical
|
||||
"security-sessions-card.tsx",
|
||||
]);
|
||||
|
||||
// The primitive itself owns the canonical `<h2 text-lg>`.
|
||||
const PRIMITIVE = "_card-header.tsx";
|
||||
|
||||
function walk(dir: string, out: string[] = []): string[] {
|
||||
for (const name of readdirSync(dir)) {
|
||||
if (name === "__tests__" || name === "node_modules") continue;
|
||||
const p = join(dir, name);
|
||||
const stat = statSync(p);
|
||||
if (stat.isDirectory()) {
|
||||
walk(p, out);
|
||||
} else if (name.endsWith("-section.tsx") || name.endsWith("-card.tsx")) {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// A hand-rolled card header: a `<h2` tag whose className (within a small
|
||||
// window, so a multi-line opening tag still matches) carries `text-lg`.
|
||||
const AD_HOC_HEADER = /<h2\b[\s\S]{0,160}?text-lg/;
|
||||
|
||||
describe("Settings card headers route through SettingsCardHeader", () => {
|
||||
it("has no hand-rolled <h2 text-lg> outside the primitive + allowlist", () => {
|
||||
const offenders: string[] = [];
|
||||
|
||||
for (const file of walk(SETTINGS_DIR)) {
|
||||
const base = file.slice(file.lastIndexOf("/") + 1);
|
||||
if (base === PRIMITIVE) continue;
|
||||
if (ALLOWLIST.has(base)) continue;
|
||||
|
||||
const source = readFileSync(file, "utf8");
|
||||
if (AD_HOC_HEADER.test(source)) {
|
||||
offenders.push(relative(ROOT, file));
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
offenders,
|
||||
`These Settings files still hand-roll a card header (\`<h2 ... text-lg>\`).\n` +
|
||||
`Route them through <SettingsCardHeader> (src/components/settings/_card-header.tsx),\n` +
|
||||
`or, if a header genuinely cannot use the primitive, add it to the ALLOWLIST\n` +
|
||||
`in this test with a one-line justification.\n\n` +
|
||||
offenders.map((f) => ` - ${f}`).join("\n"),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -473,27 +473,21 @@ export function AccountSection() {
|
||||
card's "Restart onboarding tour" actually broke through the
|
||||
right border by ~48 px). */}
|
||||
<SettingsCard>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<Shield className="text-muted-foreground mt-0.5 h-5 w-5 shrink-0" />
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("settings.passwordReset")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.changePasswordDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setPasswordDialogOpen(true)}
|
||||
className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto"
|
||||
>
|
||||
{t("settings.changePassword")}
|
||||
</Button>
|
||||
</div>
|
||||
<SettingsCardHeader
|
||||
icon={Shield}
|
||||
title={t("settings.passwordReset")}
|
||||
description={t("settings.changePasswordDescription")}
|
||||
status={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setPasswordDialogOpen(true)}
|
||||
className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto"
|
||||
>
|
||||
{t("settings.changePassword")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</SettingsCard>
|
||||
|
||||
{/* v1.25.7 — active-session management + the security-activity feed
|
||||
@@ -533,6 +527,7 @@ export function AccountSection() {
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="current-password"
|
||||
autoComplete="current-password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
/>
|
||||
@@ -543,6 +538,7 @@ export function AccountSection() {
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="new-password"
|
||||
autoComplete="new-password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
/>
|
||||
@@ -553,6 +549,7 @@ export function AccountSection() {
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="confirm-password"
|
||||
autoComplete="new-password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
/>
|
||||
|
||||
@@ -258,72 +258,69 @@ function DataResetCard() {
|
||||
GitHub-style (red CTA only) rather than red-on-red-on-red.
|
||||
The protective gate (confirmation dialog) is unchanged; this
|
||||
is purely a visual-tone fix per the v1.4.43 audit. */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-foreground text-lg font-semibold">
|
||||
{t("settings.dangerZone")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.dangerZoneDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<AlertDialog
|
||||
open={confirmOpen}
|
||||
onOpenChange={(open) => {
|
||||
// Hold the dialog open while the destructive mutation is in
|
||||
// flight so the in-dialog pending state stays visible and a
|
||||
// stray backdrop tap can't dismiss it mid-request.
|
||||
if (deleting) return;
|
||||
setConfirmOpen(open);
|
||||
}}
|
||||
>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={deleting}
|
||||
className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto"
|
||||
>
|
||||
{deleting ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("settings.dangerZone")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.dangerZoneConfirm")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.dangerZoneConfirmDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleting}>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
<SettingsCardHeader
|
||||
icon={Trash2}
|
||||
title={t("settings.dangerZone")}
|
||||
description={t("settings.dangerZoneDescription")}
|
||||
status={
|
||||
<AlertDialog
|
||||
open={confirmOpen}
|
||||
onOpenChange={(open) => {
|
||||
// Hold the dialog open while the destructive mutation is in
|
||||
// flight so the in-dialog pending state stays visible and a
|
||||
// stray backdrop tap can't dismiss it mid-request.
|
||||
if (deleting) return;
|
||||
setConfirmOpen(open);
|
||||
}}
|
||||
>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void handleDeleteAllData();
|
||||
}}
|
||||
size="sm"
|
||||
disabled={deleting}
|
||||
aria-busy={deleting || undefined}
|
||||
data-slot="settings-data-reset-confirm"
|
||||
className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto"
|
||||
>
|
||||
{deleting && (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
{deleting ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("settings.finalDelete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
{t("settings.dangerZone")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.dangerZoneConfirm")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.dangerZoneConfirmDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleting}>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void handleDeleteAllData();
|
||||
}}
|
||||
disabled={deleting}
|
||||
aria-busy={deleting || undefined}
|
||||
data-slot="settings-data-reset-confirm"
|
||||
>
|
||||
{deleting && (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("settings.finalDelete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
}
|
||||
/>
|
||||
|
||||
{msg && (
|
||||
<p
|
||||
@@ -398,73 +395,70 @@ function AccountDeleteCard() {
|
||||
|
||||
return (
|
||||
<SettingsCard data-slot="settings-account-delete-card">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-foreground text-lg font-semibold">
|
||||
{t("settings.deleteAccountCardTitle")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.deleteAccountCardDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<AlertDialog
|
||||
open={confirmOpen}
|
||||
onOpenChange={(open) => {
|
||||
// Hold the dialog open while the irreversible delete runs so
|
||||
// the pending state stays on screen and a stray dismissal
|
||||
// can't fire a second request.
|
||||
if (deleting) return;
|
||||
setConfirmOpen(open);
|
||||
}}
|
||||
>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={deleting}
|
||||
className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto"
|
||||
data-slot="settings-account-delete-trigger"
|
||||
>
|
||||
{deleting ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("settings.deleteAccountCta")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.deleteAccountConfirmTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.deleteAccountConfirmDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleting}>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
<SettingsCardHeader
|
||||
icon={Trash2}
|
||||
title={t("settings.deleteAccountCardTitle")}
|
||||
description={t("settings.deleteAccountCardDescription")}
|
||||
status={
|
||||
<AlertDialog
|
||||
open={confirmOpen}
|
||||
onOpenChange={(open) => {
|
||||
// Hold the dialog open while the irreversible delete runs so
|
||||
// the pending state stays on screen and a stray dismissal
|
||||
// can't fire a second request.
|
||||
if (deleting) return;
|
||||
setConfirmOpen(open);
|
||||
}}
|
||||
>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void handleDeleteAccount();
|
||||
}}
|
||||
size="sm"
|
||||
disabled={deleting}
|
||||
aria-busy={deleting || undefined}
|
||||
data-slot="settings-account-delete-confirm"
|
||||
className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto"
|
||||
data-slot="settings-account-delete-trigger"
|
||||
>
|
||||
{deleting && (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
{deleting ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("settings.deleteAccountFinal")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
{t("settings.deleteAccountCta")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.deleteAccountConfirmTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.deleteAccountConfirmDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleting}>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void handleDeleteAccount();
|
||||
}}
|
||||
disabled={deleting}
|
||||
aria-busy={deleting || undefined}
|
||||
data-slot="settings-account-delete-confirm"
|
||||
>
|
||||
{deleting && (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("settings.deleteAccountFinal")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
}
|
||||
/>
|
||||
|
||||
{msg && (
|
||||
<p
|
||||
|
||||
@@ -52,6 +52,7 @@ import { apiDelete, apiGet, apiPut } from "@/lib/api/api-fetch";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { WIDGET_MODULE_BY_ID } from "@/lib/dashboard/widget-modules";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
|
||||
/**
|
||||
* v1.4.47 W4 — pure reorder helper shared by the arrow buttons and the
|
||||
@@ -336,24 +337,22 @@ export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
right-align-on-desktop contract as Account → Password +
|
||||
Restart onboarding tour. Avoids the long German "Auf Standard
|
||||
zurücksetzen" copy clipping the card edge at narrow widths. */}
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<LayoutDashboard className="text-muted-foreground h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("dashboard.customizeTitle")}
|
||||
</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => resetMutation.mutate()}
|
||||
disabled={resetMutation.isPending}
|
||||
className="min-h-11 self-end sm:min-h-9 sm:self-auto"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
{t("dashboard.layoutReset")}
|
||||
</Button>
|
||||
</div>
|
||||
<SettingsCardHeader
|
||||
icon={LayoutDashboard}
|
||||
title={t("dashboard.customizeTitle")}
|
||||
status={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => resetMutation.mutate()}
|
||||
disabled={resetMutation.isPending}
|
||||
className="min-h-11 self-end sm:min-h-9 sm:self-auto"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
{t("dashboard.layoutReset")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{/*
|
||||
v1.4.22 D / F-32 — the surrounding `<DashboardSection>` page
|
||||
already renders a `settings.sections.dashboard.description`
|
||||
|
||||
@@ -522,6 +522,9 @@ function FullBackupCard() {
|
||||
"settings.sections.export.cards.fullBackup.passphrasePlaceholder",
|
||||
)}
|
||||
autoComplete="new-password"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
data-bwignore="true"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
|
||||
@@ -24,7 +24,10 @@
|
||||
* `/api/share-links` routes, and the public `/c/[token]` view are unchanged.
|
||||
*/
|
||||
|
||||
import { Share2 } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { HealthRecordExportPanel } from "@/components/settings/health-record-export-panel";
|
||||
import { SharingSection } from "@/components/settings/sharing-section";
|
||||
|
||||
@@ -39,14 +42,11 @@ export function GesundheitsakteSection() {
|
||||
<HealthRecordExportPanel />
|
||||
|
||||
<section id="sharing" className="scroll-mt-28 space-y-4">
|
||||
<div className="space-y-0.5">
|
||||
<h2 className="text-foreground text-lg font-semibold">
|
||||
{t("settings.sections.sharing.title")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.sections.sharing.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<SettingsCardHeader
|
||||
icon={Share2}
|
||||
title={t("settings.sections.sharing.title")}
|
||||
description={t("settings.sections.sharing.subtitle")}
|
||||
/>
|
||||
<SharingSection />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { LayoutGrid, Loader2 } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
import { useInsightsLayoutQuery } from "@/hooks/use-insights-layout";
|
||||
@@ -50,17 +51,12 @@ export function InsightsOverviewArrangeSection({ id }: { id?: string }) {
|
||||
aria-labelledby="insights-overview-arrange-title"
|
||||
className="space-y-3"
|
||||
>
|
||||
<header className="space-y-1">
|
||||
<h2
|
||||
id="insights-overview-arrange-title"
|
||||
className="text-lg font-semibold"
|
||||
>
|
||||
{t("insights.settings.overviewTitle")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("insights.settings.overviewDescription")}
|
||||
</p>
|
||||
</header>
|
||||
<SettingsCardHeader
|
||||
icon={LayoutGrid}
|
||||
titleId="insights-overview-arrange-title"
|
||||
title={t("insights.settings.overviewTitle")}
|
||||
description={t("insights.settings.overviewDescription")}
|
||||
/>
|
||||
|
||||
{/* Gate the editor mount until the layout GET settles. The editor seeds
|
||||
its draft once from `layout` on mount, which is the canonical default
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Eye, EyeOff, GripVertical, Loader2 } from "lucide-react";
|
||||
import { Eye, EyeOff, GripVertical, ListOrdered, Loader2 } from "lucide-react";
|
||||
import {
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
import { SUB_PAGE_TABS } from "@/components/insights/insights-tab-strip";
|
||||
import { apiPut } from "@/lib/api/api-fetch";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
|
||||
/**
|
||||
* v1.15.18 — dedicated pill-sort control for the Insights settings section.
|
||||
@@ -210,28 +211,26 @@ export function InsightsPillOrderSection({ id }: { id?: string }) {
|
||||
aria-labelledby="insights-pill-order-title"
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-3">
|
||||
<div className="space-y-1">
|
||||
<h2 id="insights-pill-order-title" className="text-lg font-semibold">
|
||||
{t("insights.pillOrder.title")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("insights.pillOrder.description")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => saveMutation.mutate(draftTiles)}
|
||||
disabled={busy || !dirty}
|
||||
data-slot="insights-pill-order-save"
|
||||
className="min-h-11 self-end sm:min-h-9 sm:self-auto"
|
||||
>
|
||||
{saveMutation.isPending && (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("insights.pillOrder.save")}
|
||||
</Button>
|
||||
</div>
|
||||
<SettingsCardHeader
|
||||
icon={ListOrdered}
|
||||
titleId="insights-pill-order-title"
|
||||
title={t("insights.pillOrder.title")}
|
||||
description={t("insights.pillOrder.description")}
|
||||
status={
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => saveMutation.mutate(draftTiles)}
|
||||
disabled={busy || !dirty}
|
||||
data-slot="insights-pill-order-save"
|
||||
className="min-h-11 self-end sm:min-h-9 sm:self-auto"
|
||||
>
|
||||
{saveMutation.isPending && (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("insights.pillOrder.save")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<p
|
||||
|
||||
@@ -21,7 +21,10 @@
|
||||
* `connections-panel.tsx` so existing unit-test imports keep resolving.
|
||||
*/
|
||||
|
||||
import { Link2, Send } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { ConnectionsPanel } from "@/components/settings/integrations/connections-panel";
|
||||
import { NotificationChannelsPanel } from "@/components/settings/integrations/notification-channels-panel";
|
||||
|
||||
@@ -38,21 +41,30 @@ export function IntegrationsSection() {
|
||||
// primary surface, with the delivery-channels group sequenced below it.
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
<ConnectionsPanel />
|
||||
{/* Connections ("where the data comes from"). The OAuth + device data
|
||||
sources. A labelled group header gives this block parity with the
|
||||
delivery-channels group below it. */}
|
||||
<section className="space-y-4">
|
||||
<SettingsCardHeader
|
||||
icon={Link2}
|
||||
title={t("settings.sections.integrations.connectionsHeading")}
|
||||
description={t(
|
||||
"settings.sections.integrations.connectionsDescription",
|
||||
)}
|
||||
/>
|
||||
<ConnectionsPanel />
|
||||
</section>
|
||||
|
||||
{/* Delivery channels ("where it's delivered"). The existing channels
|
||||
panel, unchanged. `id="channels"` anchors the group for the
|
||||
`/settings/channels` redirect; the per-channel anchors stay inside
|
||||
the panel. */}
|
||||
<section id="channels" className="scroll-mt-28 space-y-4">
|
||||
<div className="space-y-0.5">
|
||||
<h2 className="text-foreground text-lg font-semibold">
|
||||
{t("settings.sections.integrations.channelsHeading")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.sections.integrations.channelsDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<SettingsCardHeader
|
||||
icon={Send}
|
||||
title={t("settings.sections.integrations.channelsHeading")}
|
||||
description={t("settings.sections.integrations.channelsDescription")}
|
||||
/>
|
||||
<NotificationChannelsPanel />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { JSX } from "react";
|
||||
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
import { DashboardSection } from "./dashboard-section";
|
||||
import { InsightsSection } from "./insights-section";
|
||||
import { MedicationsSection } from "./medications-section";
|
||||
import { MoodSection } from "./mood-section";
|
||||
import { LabsSection } from "./labs-section";
|
||||
import { IllnessSection } from "./illness-section";
|
||||
import { VorsorgeSection } from "./vorsorge-section";
|
||||
|
||||
/**
|
||||
* v1.25.11 (#148) — single source of truth for the "Appearance" (slug
|
||||
* `layout`) hub + its per-module subpages.
|
||||
*
|
||||
* "How my app looks and is arranged" was scattered across several unrelated
|
||||
* settings sections. The `layout` slug is the one front door. v1.25.7 stacked
|
||||
* every module's view/sort surface inline on a single page; #148 splits that
|
||||
* back into a HUB → SUBPAGE model: `/settings/layout` lists the modules as
|
||||
* clickable rows, and each row opens `/settings/layout/<id>`, which renders
|
||||
* ONLY that module's section (the same existing section component, verbatim)
|
||||
* with a "← Appearance" back-link.
|
||||
*
|
||||
* This module is intentionally NOT `"use client"`: the client hub
|
||||
* (`layout-section.tsx`) AND the server subpage route
|
||||
* (`app/settings/layout/[module]/page.tsx`, which reads `LAYOUT_GROUP_IDS` in
|
||||
* `generateStaticParams()`) both consume it, so its array values must stay
|
||||
* usable on the server. It holds references to the `"use client"` section
|
||||
* components, which a server component renders as ordinary client boundaries.
|
||||
*
|
||||
* Each module section keeps its module-gate: a disabled module's row / subpage
|
||||
* does not render, read from the resolved `useAuth().user.modules` map (the
|
||||
* same map the nav, the Modules hub, and the Insights pills gate off). The gate
|
||||
* fails OPEN (`!== false`) so a stale `/me` payload never blanks an entry.
|
||||
* Vorsorge (preventive-care reminders) is not a toggleable module, so it always
|
||||
* shows.
|
||||
*/
|
||||
export interface LayoutGroup {
|
||||
/** Stable id — the subpage path segment (`/settings/layout/<id>`). */
|
||||
id: string;
|
||||
/** i18n key under `settings.sections.layout.<slug>.title`. */
|
||||
titleKey: string;
|
||||
/** i18n key under `settings.sections.layout.<slug>.description`. */
|
||||
descriptionKey: string;
|
||||
Body: () => JSX.Element | null;
|
||||
/**
|
||||
* When set, the row / subpage renders only while the module is enabled
|
||||
* (fail-open, `!== false`). Omitted = always shown.
|
||||
*/
|
||||
moduleGate?: ModuleKey;
|
||||
}
|
||||
|
||||
export const LAYOUT_GROUPS: ReadonlyArray<LayoutGroup> = [
|
||||
{
|
||||
id: "dashboard",
|
||||
titleKey: "settings.sections.layout.dashboard.title",
|
||||
descriptionKey: "settings.sections.layout.dashboard.description",
|
||||
Body: DashboardSection,
|
||||
},
|
||||
{
|
||||
id: "insights",
|
||||
titleKey: "settings.sections.layout.insights.title",
|
||||
descriptionKey: "settings.sections.layout.insights.description",
|
||||
Body: InsightsSection,
|
||||
},
|
||||
{
|
||||
id: "medications",
|
||||
titleKey: "settings.sections.layout.medications.title",
|
||||
descriptionKey: "settings.sections.layout.medications.description",
|
||||
Body: MedicationsSection,
|
||||
moduleGate: "medications",
|
||||
},
|
||||
{
|
||||
id: "mood",
|
||||
titleKey: "settings.sections.layout.mood.title",
|
||||
descriptionKey: "settings.sections.layout.mood.description",
|
||||
Body: MoodSection,
|
||||
moduleGate: "mood",
|
||||
},
|
||||
{
|
||||
id: "labs",
|
||||
titleKey: "settings.sections.layout.labs.title",
|
||||
descriptionKey: "settings.sections.layout.labs.description",
|
||||
Body: LabsSection,
|
||||
moduleGate: "labs",
|
||||
},
|
||||
{
|
||||
id: "illness",
|
||||
titleKey: "settings.sections.layout.illness.title",
|
||||
descriptionKey: "settings.sections.layout.illness.description",
|
||||
Body: IllnessSection,
|
||||
moduleGate: "illness",
|
||||
},
|
||||
{
|
||||
id: "vorsorge",
|
||||
titleKey: "settings.sections.layout.vorsorge.title",
|
||||
descriptionKey: "settings.sections.layout.vorsorge.description",
|
||||
Body: VorsorgeSection,
|
||||
},
|
||||
];
|
||||
|
||||
/** Ordered list of the subpage path segments — server-safe for `generateStaticParams()`. */
|
||||
export const LAYOUT_GROUP_IDS: readonly string[] = LAYOUT_GROUPS.map(
|
||||
(group) => group.id,
|
||||
);
|
||||
|
||||
export function isLayoutGroupId(value: string): boolean {
|
||||
return LAYOUT_GROUP_IDS.includes(value);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
|
||||
/**
|
||||
* v1.25.11 (#148) — client gate for an Appearance subpage
|
||||
* (`/settings/layout/<module>`).
|
||||
*
|
||||
* A module's subpage may be reached by direct URL even when the user has the
|
||||
* module turned off. This wrapper fails OPEN: it renders the section unless the
|
||||
* resolved `useAuth().user.modules` map has the gate key explicitly `false`,
|
||||
* in which case it bounces back to the hub (`/settings/layout`).
|
||||
*
|
||||
* The gate is hydration-stable: `useMounted()` returns `false` during SSR AND
|
||||
* the first client paint, so the first render ALWAYS shows the section
|
||||
* (matching the server HTML); the real check applies once, after hydration, as
|
||||
* an ordinary client update. The redirect is a client-only effect, so it never
|
||||
* diverges the initial render and cannot trigger a React #418 mismatch.
|
||||
*
|
||||
* Groups with no `moduleGate` (dashboard / insights / vorsorge) pass
|
||||
* `moduleGate={undefined}` and always render.
|
||||
*/
|
||||
export function LayoutModuleGate({
|
||||
moduleGate,
|
||||
children,
|
||||
}: {
|
||||
moduleGate?: ModuleKey;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const hydrated = useMounted();
|
||||
const { user } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const disabled =
|
||||
hydrated &&
|
||||
moduleGate !== undefined &&
|
||||
user?.modules?.[moduleGate] === false;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (disabled) {
|
||||
router.replace("/settings/layout");
|
||||
}
|
||||
}, [disabled, router]);
|
||||
|
||||
if (disabled) return null;
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -1,146 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
import { DashboardSection } from "./dashboard-section";
|
||||
import { InsightsSection } from "./insights-section";
|
||||
import { MedicationsSection } from "./medications-section";
|
||||
import { MoodSection } from "./mood-section";
|
||||
import { LabsSection } from "./labs-section";
|
||||
import { IllnessSection } from "./illness-section";
|
||||
import { VorsorgeSection } from "./vorsorge-section";
|
||||
import { SettingsCard } from "./settings-card";
|
||||
import { LAYOUT_GROUPS } from "./layout-groups";
|
||||
|
||||
/**
|
||||
* v1.17.1 (F-2) — the "Appearance" home (slug stays `layout`).
|
||||
* v1.25.11 (#148) — the "Appearance" hub (slug stays `layout`).
|
||||
*
|
||||
* "How my app looks and is arranged" was scattered across several unrelated
|
||||
* settings sections with no shared framing. This section is the single front
|
||||
* door for every view/arrangement surface.
|
||||
* 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/<id>`. 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` slug; this body renders only the row list.
|
||||
*
|
||||
* v1.25.7 — the hub stops being a link list and becomes the page itself: the
|
||||
* dashboard, insights, and every tracking module's settings render inline as
|
||||
* stacked, labelled sections, composing the EXISTING section components
|
||||
* verbatim. The per-module settings pages are no longer standalone left-nav
|
||||
* entries — their routes 301-redirect to the matching anchor here
|
||||
* (`/settings/medications` → `/settings/layout#medications`, …). Each module
|
||||
* section keeps its module-gate: a disabled module's section does not render,
|
||||
* read from the resolved `useAuth().user.modules` map (the same map the nav,
|
||||
* the Modules hub, and the Insights pills gate off). The gate fails OPEN
|
||||
* (`!== false`) so a stale `/me` payload never blanks a section. Vorsorge
|
||||
* (preventive-care reminders) is not a toggleable module, so it always shows.
|
||||
*
|
||||
* Each section carries a stable anchor id (`#dashboard`, `#insights`,
|
||||
* `#medications`, `#mood`, `#labs`, `#illness`, `#vorsorge`) so deep links and
|
||||
* the per-module page-header cogs land on the right block. The inner card
|
||||
* anchors inside each module section (`#medications-view`, `#labs-view`, …)
|
||||
* keep working too.
|
||||
*
|
||||
* Visual rhythm mirrors the multi-group Notifications surface 1:1 — outer
|
||||
* `space-y-10`, each group a `<section>` with a `space-y-0.5` heading block and
|
||||
* the section body below. No new card chrome is introduced; every card paints
|
||||
* through its own existing component unchanged.
|
||||
* Per-module gating fails OPEN (`!== false`): a missing key reads as enabled so
|
||||
* a section never silently disappears. Groups with no `moduleGate` always
|
||||
* render. The filter is gated on a post-mount flag (`useMounted()`) so SSR and
|
||||
* the first client paint ALWAYS emit the same fail-open list — the same
|
||||
* hydration-stability contract the shell nav uses, so the module filter can
|
||||
* never produce a React #418 mismatch on this surface.
|
||||
*/
|
||||
interface LayoutGroup {
|
||||
/** Anchor id + redirect target (`/settings/layout#<id>`). */
|
||||
id: string;
|
||||
/** i18n key under `settings.sections.layout.<slug>.title`. */
|
||||
titleKey: string;
|
||||
/** i18n key under `settings.sections.layout.<slug>.description`. */
|
||||
descriptionKey: string;
|
||||
Body: () => React.JSX.Element | null;
|
||||
/**
|
||||
* When set, the group renders only while the module is enabled (fail-open,
|
||||
* `!== false`). Omitted = always shown.
|
||||
*/
|
||||
moduleGate?: ModuleKey;
|
||||
}
|
||||
|
||||
const LAYOUT_GROUPS: ReadonlyArray<LayoutGroup> = [
|
||||
{
|
||||
id: "dashboard",
|
||||
titleKey: "settings.sections.layout.dashboard.title",
|
||||
descriptionKey: "settings.sections.layout.dashboard.description",
|
||||
Body: DashboardSection,
|
||||
},
|
||||
{
|
||||
id: "insights",
|
||||
titleKey: "settings.sections.layout.insights.title",
|
||||
descriptionKey: "settings.sections.layout.insights.description",
|
||||
Body: InsightsSection,
|
||||
},
|
||||
{
|
||||
id: "medications",
|
||||
titleKey: "settings.sections.layout.medications.title",
|
||||
descriptionKey: "settings.sections.layout.medications.description",
|
||||
Body: MedicationsSection,
|
||||
moduleGate: "medications",
|
||||
},
|
||||
{
|
||||
id: "mood",
|
||||
titleKey: "settings.sections.layout.mood.title",
|
||||
descriptionKey: "settings.sections.layout.mood.description",
|
||||
Body: MoodSection,
|
||||
moduleGate: "mood",
|
||||
},
|
||||
{
|
||||
id: "labs",
|
||||
titleKey: "settings.sections.layout.labs.title",
|
||||
descriptionKey: "settings.sections.layout.labs.description",
|
||||
Body: LabsSection,
|
||||
moduleGate: "labs",
|
||||
},
|
||||
{
|
||||
id: "illness",
|
||||
titleKey: "settings.sections.layout.illness.title",
|
||||
descriptionKey: "settings.sections.layout.illness.description",
|
||||
Body: IllnessSection,
|
||||
moduleGate: "illness",
|
||||
},
|
||||
{
|
||||
id: "vorsorge",
|
||||
titleKey: "settings.sections.layout.vorsorge.title",
|
||||
descriptionKey: "settings.sections.layout.vorsorge.description",
|
||||
Body: VorsorgeSection,
|
||||
},
|
||||
];
|
||||
|
||||
export function LayoutSection() {
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
|
||||
// v1.25.7 — per-module gating. Fail OPEN (`!== false`): a missing key, or a
|
||||
// not-yet-resolved `/me` payload, reads as enabled so a section never
|
||||
// silently disappears. Groups with no `moduleGate` always render.
|
||||
const hydrated = useMounted();
|
||||
const modules = user?.modules;
|
||||
const visibleGroups = LAYOUT_GROUPS.filter(
|
||||
(group) => !group.moduleGate || modules?.[group.moduleGate] !== false,
|
||||
(group) =>
|
||||
!hydrated || !group.moduleGate || modules?.[group.moduleGate] !== false,
|
||||
);
|
||||
|
||||
// The visible page heading + subtitle are painted by `SettingsShell` from
|
||||
// the section slug; this body stacks the per-surface sections below it.
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
{visibleGroups.map((group) => {
|
||||
const { Body } = group;
|
||||
return (
|
||||
<section
|
||||
key={group.id}
|
||||
id={group.id}
|
||||
className="scroll-mt-28 space-y-4"
|
||||
>
|
||||
<div className="space-y-0.5">
|
||||
<h2 className="text-foreground text-lg font-semibold">
|
||||
{t(group.titleKey)}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t(group.descriptionKey)}
|
||||
</p>
|
||||
</div>
|
||||
<Body />
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<SettingsCard className="divide-border divide-y overflow-hidden p-0 md:p-0">
|
||||
{visibleGroups.map((group) => (
|
||||
<Link
|
||||
key={group.id}
|
||||
href={`/settings/layout/${group.id}`}
|
||||
className="hover:bg-accent/50 flex items-center justify-between gap-4 p-4 transition-colors md:p-6"
|
||||
>
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
{t(group.titleKey)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t(group.descriptionKey)}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight
|
||||
className="text-muted-foreground h-5 w-5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Link>
|
||||
))}
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
@@ -65,6 +66,7 @@ const TEST_ENDPOINTS: Record<ChannelType, string> = {
|
||||
};
|
||||
|
||||
export function NotificationStatusCard() {
|
||||
const hydrated = useMounted();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
@@ -108,7 +110,14 @@ export function NotificationStatusCard() {
|
||||
},
|
||||
});
|
||||
|
||||
if (!isAuthenticated) return null;
|
||||
// SSR and the first client paint must render the SAME tree shape. `useAuth`
|
||||
// reads a client TanStack query, so `isAuthenticated` is `false` on the
|
||||
// server (renders `null`) but `true` on the hydrating client (would render
|
||||
// the loading card) — different shapes → React #418 hydration mismatch.
|
||||
// Gate every auth/loading-dependent branch behind the post-mount probe so
|
||||
// both passes render `null`; the real content lands as an ordinary
|
||||
// post-hydration client update, not a hydration-time branch.
|
||||
if (!hydrated || !isAuthenticated) return null;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { Bell } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { LowStockCard } from "@/components/settings/low-stock-card";
|
||||
import { MoodReminderCard } from "@/components/settings/mood-reminder-card";
|
||||
|
||||
@@ -46,14 +49,13 @@ export function NotificationsSection() {
|
||||
account never shows an empty heading. */}
|
||||
{anyReminder ? (
|
||||
<section className="space-y-4">
|
||||
<div className="space-y-0.5">
|
||||
<h2 className="text-foreground text-lg font-semibold">
|
||||
{t("settings.sections.notifications.remindersHeading")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.sections.notifications.remindersDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<SettingsCardHeader
|
||||
icon={Bell}
|
||||
title={t("settings.sections.notifications.remindersHeading")}
|
||||
description={t(
|
||||
"settings.sections.notifications.remindersDescription",
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Mood check-in reminder — only when the mood module is enabled. */}
|
||||
{moodEnabled ? (
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
import {
|
||||
@@ -144,8 +145,9 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
|
||||
// v1.25.7 — "Darstellung" (Appearance) absorbs the per-module view/sort/
|
||||
// order surfaces. The standalone Medikamente / Stimmung / Labor /
|
||||
// Krankheit / Vorsorge nav entries are gone: each module's full settings
|
||||
// now render inline inside this section, gated on the same module key. The
|
||||
// old routes 301-redirect to `/settings/layout#<anchor>` (next.config.ts).
|
||||
// v1.25.11 (#148) — "Darstellung" is a HUB that lists each module and links
|
||||
// to its own subpage at `/settings/layout/<module>`. The old per-module
|
||||
// routes 301-redirect to the matching subpage (next.config.ts).
|
||||
{
|
||||
slug: "layout",
|
||||
titleKey: "settings.sections.layout.title",
|
||||
@@ -171,7 +173,7 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
|
||||
},
|
||||
// v1.25.7 — Vorsorge (preventive-care reminders) folded into "Darstellung"
|
||||
// alongside the other tracking modules; `/settings/vorsorge` 301-redirects
|
||||
// to `/settings/layout#vorsorge` (next.config.ts).
|
||||
// to `/settings/layout/vorsorge` (next.config.ts).
|
||||
{
|
||||
slug: "thresholds",
|
||||
titleKey: "settings.sections.thresholds.title",
|
||||
@@ -280,8 +282,9 @@ const LAYOUT_CHILD_SLUGS: ReadonlySet<string> = new Set([
|
||||
"dashboard",
|
||||
"insights",
|
||||
// v1.25.7 — the per-module view/sort/order surfaces moved into the
|
||||
// "Darstellung" hub. Their routes 301-redirect to `/settings/layout#<slug>`,
|
||||
// but if one is ever reached directly the nav still highlights the hub.
|
||||
// "Darstellung" hub. v1.25.11 (#148) — each lives on its own subpage at
|
||||
// `/settings/layout/<slug>`; the legacy `/settings/<slug>` routes
|
||||
// 301-redirect there, and a direct hit still highlights the hub.
|
||||
"medications",
|
||||
"mood",
|
||||
"labs",
|
||||
@@ -294,10 +297,6 @@ function navHighlightSlug(slug: SettingsSectionSlug): SettingsSectionSlug {
|
||||
return LAYOUT_CHILD_SLUGS.has(slug) ? "layout" : slug;
|
||||
}
|
||||
|
||||
// Stable no-op subscribe for the hydration probe below — the value never
|
||||
// changes after the first commit, so there is nothing to subscribe to.
|
||||
const subscribeNoop = () => () => {};
|
||||
|
||||
function deriveActiveSlug(
|
||||
pathname: string | null,
|
||||
override?: SettingsSectionSlug,
|
||||
@@ -340,16 +339,12 @@ export function SettingsShell({
|
||||
// download buttons). Gate the filter on a post-mount flag so SSR and the
|
||||
// first client render ALWAYS emit the same fail-open list; the real filter
|
||||
// applies once, after hydration, as an ordinary client update.
|
||||
// `useSyncExternalStore` is the SSR-safe hydration probe: it returns the
|
||||
// server snapshot (`false`) on the server render AND the first client paint,
|
||||
// then the client snapshot (`true`) after hydration commits. No setState in
|
||||
// an effect (which the lint rule flags as a cascading render), and no risk
|
||||
// of the two passes disagreeing.
|
||||
const hydrated = React.useSyncExternalStore(
|
||||
subscribeNoop,
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
// `useMounted()` is the SSR-safe hydration probe: it returns the server
|
||||
// snapshot (`false`) on the server render AND the first client paint, then
|
||||
// the client snapshot (`true`) after hydration commits. No setState in an
|
||||
// effect (which the lint rule flags as a cascading render), and no risk of
|
||||
// the two passes disagreeing.
|
||||
const hydrated = useMounted();
|
||||
const modules = user?.modules;
|
||||
const visibleSections = SETTINGS_SECTIONS.filter(
|
||||
(section) =>
|
||||
@@ -511,7 +506,7 @@ export function SettingsShell({
|
||||
holds across locales and however the title or subtitle wraps. No
|
||||
fixed-height spacer to guess. The `gap-6` between rows reproduces
|
||||
the `space-y-6` the heading-to-first-card gap used to carry. */}
|
||||
<div className="grid gap-6 md:grid-cols-[220px_1fr] md:grid-rows-[auto_1fr]">
|
||||
<div className="grid gap-6 md:min-h-[calc(100dvh-10.5rem)] md:grid-cols-[220px_1fr] md:grid-rows-[auto_1fr]">
|
||||
{/* Heading — desktop only here (mobile renders it above the strip). */}
|
||||
{headingBlock ? (
|
||||
<div className="hidden md:col-start-2 md:row-start-1 md:block">
|
||||
@@ -560,11 +555,20 @@ export function SettingsShell({
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
{/* Main column — page renders its own h1 + subtitle. The
|
||||
`min-h-[calc(100dvh-12rem)]` reserve keeps the column tall
|
||||
enough that swapping a short loading state for a long
|
||||
section list (Thresholds, Sources) does not jump the page
|
||||
height under the sticky sidebar.
|
||||
{/* Main column — page renders its own h1 + subtitle.
|
||||
|
||||
v1.25.11 (#154) — the column no longer carries its own
|
||||
`min-h-[calc(100dvh-12rem)]`. That reserve was tuned without
|
||||
the AuthShell wrapper's `pt-6 pb-20` (104 px) in the budget, so
|
||||
on a short section it forced the column ~100 px past the
|
||||
viewport: the page scrolled into a dark empty band below the
|
||||
last card. Instead the GRID carries `md:min-h-[calc(100dvh-
|
||||
10.5rem)]` (top bar 4rem + wrapper pt-6/pb-20 = 10.5rem), and
|
||||
this column is the grid's `1fr` row, so it stretches to fill
|
||||
the viewport EXACTLY on a short section (no over-scroll, no dark
|
||||
band) and grows past it on a long one (normal scroll). The floor
|
||||
still prevents the short-loading-state → long-list height jump
|
||||
the old reserve guarded, just measured against the real budget.
|
||||
|
||||
v1.4.33 F14 — `pb-24 md:pb-0` reserves a 96 px bottom gutter
|
||||
on `<md` so the last form field on
|
||||
@@ -576,7 +580,7 @@ export function SettingsShell({
|
||||
v1.16.4 — a `<div>`, not `<main>`: the surrounding AuthShell
|
||||
already provides the page's single `<main>` landmark and a
|
||||
nested second one is an a11y violation. */}
|
||||
<div className="min-h-[calc(100dvh-12rem)] min-w-0 pb-24 md:col-start-2 md:row-start-2 md:pb-0">
|
||||
<div className="min-w-0 pb-24 md:col-start-2 md:row-start-2 md:pb-0">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,21 +1,29 @@
|
||||
/**
|
||||
* `<DateField>` contract (v1.21.0).
|
||||
* `<DateField>` contract (v1.25.11 — app-controlled calendar).
|
||||
*
|
||||
* The SSR-only convention (no `@testing-library/react`, `environment: "node"`)
|
||||
* means the interactive picker / typed-entry paths can't be driven here — those
|
||||
* ride `showPicker()` + change events the static renderer never fires. What the
|
||||
* static markup CAN pin is the load-bearing contract:
|
||||
* The native `<input type="date">` picker is gone: the date field now opens a
|
||||
* shadcn `<Calendar>` (react-day-picker) inside a `<Popover>`, so the picker
|
||||
* renders identically on every browser / OS. The popover content only mounts
|
||||
* once the popover is open, so under the SSR-only convention (no
|
||||
* `@testing-library/react`, `environment: "node"`, `renderToStaticMarkup`) the
|
||||
* calendar itself isn't in the static markup — the same way the old picker /
|
||||
* typed-entry paths could never be driven here. What the static markup CAN pin
|
||||
* is the load-bearing contract:
|
||||
*
|
||||
* - the committed VALUE stays ISO `yyyy-MM-dd` on a hidden native date input,
|
||||
* so this is a drop-in for `<DateInput>` and react-hook-form is unchanged;
|
||||
* - the committed VALUE stays ISO `yyyy-MM-dd` on a hidden mirror input
|
||||
* carrying `name`, so this is a drop-in for `<DateInput>` and react-hook-
|
||||
* form is unchanged;
|
||||
* - the visible overlay paints the value formatted per the preference;
|
||||
* - disabled / min / max / placeholder thread through;
|
||||
* - height + target-size parity classes are present.
|
||||
* - disabled / placeholder thread through;
|
||||
* - height + target-size parity classes are present;
|
||||
* - the calendar popover trigger is exposed as a labelled affordance.
|
||||
*
|
||||
* Under SSR `useDateFormatPreference()` resolves AUTO (no `window`), so the
|
||||
* overlay reflects the locale order — de → dd.MM.yyyy, en → MM/dd/yyyy. The
|
||||
* per-preference order itself is covered exhaustively in
|
||||
* `src/lib/__tests__/date-format.test.ts` against the pure formatter.
|
||||
* `src/lib/__tests__/date-format.test.ts` against the pure formatter; the
|
||||
* min/max bounds (calendar matcher + ISO clamp) ride the interactive picker
|
||||
* and are not observable in the static markup.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
@@ -30,13 +38,14 @@ function render(node: React.ReactNode, locale: "de" | "en" = "en"): string {
|
||||
}
|
||||
|
||||
describe("<DateField>", () => {
|
||||
it("keeps the ISO value on a hidden native date input", () => {
|
||||
it("keeps the ISO value on a hidden mirror input carrying the name", () => {
|
||||
const html = render(<DateField id="dob" name="dob" value="2026-02-19" />);
|
||||
// The real value lives on a type=date input (the form's source of truth).
|
||||
expect(html).toMatch(/<input[^>]*type="date"[^>]*value="2026-02-19"/);
|
||||
// The real value lives on a hidden mirror input (the form's source of
|
||||
// truth + native-submit carrier), not a native date picker anymore.
|
||||
expect(html).toMatch(/<input[^>]*type="hidden"[^>]*value="2026-02-19"/);
|
||||
expect(html).toContain('name="dob"');
|
||||
// …and it is visually hidden so only the overlay shows.
|
||||
expect(html).toMatch(/<input[^>]*type="date"[^>]*class="[^"]*sr-only/);
|
||||
// …and there is no native date picker in the tree.
|
||||
expect(html).not.toContain('type="date"');
|
||||
});
|
||||
|
||||
it("paints the AUTO-locale display string over the value (en → MM/dd/yyyy)", () => {
|
||||
@@ -59,7 +68,7 @@ describe("<DateField>", () => {
|
||||
expect(html).toContain('placeholder="MM/DD/YYYY"');
|
||||
});
|
||||
|
||||
it("threads disabled, min and max onto the native input", () => {
|
||||
it("threads disabled onto the overlay and the hidden mirror input", () => {
|
||||
const html = render(
|
||||
<DateField
|
||||
value="2026-02-19"
|
||||
@@ -68,9 +77,13 @@ describe("<DateField>", () => {
|
||||
max="2030-12-31"
|
||||
/>,
|
||||
);
|
||||
expect(html).toMatch(/<input[^>]*type="date"[^>]*min="2020-01-01"/);
|
||||
expect(html).toMatch(/<input[^>]*type="date"[^>]*max="2030-12-31"/);
|
||||
expect(html).toMatch(/<input[^>]*type="date"[^>]*disabled/);
|
||||
// Both the visible overlay and the hidden mirror reflect the disabled state.
|
||||
expect(html).toMatch(/<input[^>]*type="hidden"[^>]*disabled/);
|
||||
expect(html).toMatch(/disabled/);
|
||||
// min/max are enforced by the calendar matcher + ISO clamp, not by a native
|
||||
// input attribute, so they no longer appear in the static markup.
|
||||
expect(html).not.toContain('min="2020-01-01"');
|
||||
expect(html).not.toContain('max="2030-12-31"');
|
||||
});
|
||||
|
||||
it("ships the WCAG target-size + height-parity classes", () => {
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
/**
|
||||
* `<DateTimeField>` contract (v1.25.4).
|
||||
*
|
||||
* The field now composes a native-calendar `<DateField>` (date part) with the
|
||||
* app-controlled `<TimeField>` (time part) so the time picker always follows
|
||||
* the user's hour-cycle preference rather than the browser UI language. Like
|
||||
* `<DateField>`, the SSR-only convention (`renderToStaticMarkup`,
|
||||
* `environment: "node"`) means the interactive picker path can't be driven
|
||||
* here. What the static markup pins is the load-bearing contract:
|
||||
* The field now composes an app-controlled `<DateField>` (date part, a shadcn
|
||||
* `<Calendar>` in a popover) with the app-controlled `<TimeField>` (time part)
|
||||
* so both pickers follow the user's preferences rather than the browser UI
|
||||
* language and render identically on every OS. Like `<DateField>`, the SSR-only
|
||||
* convention (`renderToStaticMarkup`, `environment: "node"`) means the
|
||||
* interactive picker path can't be driven here. What the static markup pins is
|
||||
* the load-bearing contract:
|
||||
*
|
||||
* - the committed VALUE stays a local `yyyy-MM-ddTHH:mm` string on a hidden
|
||||
* input carrying `name`, so this is a drop-in for the old field;
|
||||
* - the date overlay paints the date-order preference; the time overlay paints
|
||||
* the hour-cycle preference;
|
||||
* - disabled threads through; min / max gate the native calendar by date part;
|
||||
* - disabled threads through; min / max gate the calendar (matcher + ISO
|
||||
* clamp) and are not observable in the static markup;
|
||||
* - height + target-size parity classes are present on both halves.
|
||||
*
|
||||
* Under SSR both preferences resolve AUTO (no `window`), so each half follows
|
||||
@@ -59,7 +61,7 @@ describe("<DateTimeField>", () => {
|
||||
expect(html).toContain('placeholder="Pick a moment"');
|
||||
});
|
||||
|
||||
it("gates the native calendar by the min/max date part and threads disabled", () => {
|
||||
it("threads disabled onto both halves; min/max ride the calendar matcher", () => {
|
||||
const html = render(
|
||||
<DateTimeField
|
||||
value="2026-12-31T14:05"
|
||||
@@ -68,9 +70,14 @@ describe("<DateTimeField>", () => {
|
||||
max="2030-12-31T23:59"
|
||||
/>,
|
||||
);
|
||||
expect(html).toMatch(/<input[^>]*type="date"[^>]*min="2020-01-01"/);
|
||||
expect(html).toMatch(/<input[^>]*type="date"[^>]*max="2030-12-31"/);
|
||||
expect(html).toMatch(/<input[^>]*type="date"[^>]*disabled/);
|
||||
// No native date picker anymore — the date half is the hidden mirror +
|
||||
// overlay, both disabled.
|
||||
expect(html).not.toContain('type="date"');
|
||||
expect(html).toMatch(/<input[^>]*type="hidden"[^>]*disabled/);
|
||||
// min/max gate the popover calendar (matcher) + ISO clamp, so the date-part
|
||||
// bounds are not present as native input attributes in the static markup.
|
||||
expect(html).not.toContain('min="2020-01-01"');
|
||||
expect(html).not.toContain('max="2030-12-31"');
|
||||
});
|
||||
|
||||
it("ships the WCAG target-size + height-parity classes on both halves", () => {
|
||||
|
||||
@@ -9,6 +9,7 @@ describe("<Input>", () => {
|
||||
expect(html).toContain('autoComplete="off"');
|
||||
expect(html).toContain('data-lpignore="true"');
|
||||
expect(html).toContain('data-1p-ignore="true"');
|
||||
expect(html).toContain('data-bwignore="true"');
|
||||
});
|
||||
|
||||
it("respects an explicit autoComplete value and drops the ignore attrs", () => {
|
||||
@@ -18,6 +19,7 @@ describe("<Input>", () => {
|
||||
expect(html).toContain('autoComplete="email"');
|
||||
expect(html).not.toContain('data-lpignore="true"');
|
||||
expect(html).not.toContain('data-1p-ignore="true"');
|
||||
expect(html).not.toContain('data-bwignore="true"');
|
||||
});
|
||||
|
||||
it("explicit autoComplete=username keeps password manager active for login flows", () => {
|
||||
@@ -27,6 +29,7 @@ describe("<Input>", () => {
|
||||
expect(html).toContain('autoComplete="username"');
|
||||
expect(html).not.toContain("lpignore");
|
||||
expect(html).not.toContain("1p-ignore");
|
||||
expect(html).not.toContain("bwignore");
|
||||
});
|
||||
|
||||
it("derives inputMode=decimal from type=number", () => {
|
||||
|
||||
@@ -21,6 +21,7 @@ describe("<Textarea>", () => {
|
||||
expect(html).toContain('autoComplete="off"');
|
||||
expect(html).toContain('data-lpignore="true"');
|
||||
expect(html).toContain('data-1p-ignore="true"');
|
||||
expect(html).toContain('data-bwignore="true"');
|
||||
});
|
||||
|
||||
it("defaults autoCapitalize to sentences for prose-like free-text input", () => {
|
||||
@@ -51,6 +52,7 @@ describe("<Textarea>", () => {
|
||||
expect(html).toContain('autoComplete="on"');
|
||||
expect(html).not.toContain('data-lpignore="true"');
|
||||
expect(html).not.toContain('data-1p-ignore="true"');
|
||||
expect(html).not.toContain('data-bwignore="true"');
|
||||
});
|
||||
|
||||
it("merges caller className via cn() without dropping defaults", () => {
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DayPicker,
|
||||
getDefaultClassNames,
|
||||
type DayButton,
|
||||
} from "react-day-picker";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
captionLayout = "label",
|
||||
buttonVariant = "ghost",
|
||||
formatters,
|
||||
components,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayPicker> & {
|
||||
buttonVariant?: React.ComponentProps<typeof Button>["variant"];
|
||||
}) {
|
||||
const defaultClassNames = getDefaultClassNames();
|
||||
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn(
|
||||
"group/calendar bg-background p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
||||
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||
className,
|
||||
)}
|
||||
captionLayout={captionLayout}
|
||||
formatters={{
|
||||
formatMonthDropdown: (date) =>
|
||||
date.toLocaleString("default", { month: "short" }),
|
||||
...formatters,
|
||||
}}
|
||||
classNames={{
|
||||
root: cn("w-fit", defaultClassNames.root),
|
||||
months: cn(
|
||||
"relative flex flex-col gap-4 md:flex-row",
|
||||
defaultClassNames.months,
|
||||
),
|
||||
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
|
||||
nav: cn(
|
||||
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
|
||||
defaultClassNames.nav,
|
||||
),
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
|
||||
defaultClassNames.button_previous,
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
|
||||
defaultClassNames.button_next,
|
||||
),
|
||||
month_caption: cn(
|
||||
"flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
|
||||
defaultClassNames.month_caption,
|
||||
),
|
||||
dropdowns: cn(
|
||||
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
|
||||
defaultClassNames.dropdowns,
|
||||
),
|
||||
dropdown_root: cn(
|
||||
"relative rounded-md border border-input shadow-xs has-focus:border-ring has-focus:ring-[3px] has-focus:ring-ring/50",
|
||||
defaultClassNames.dropdown_root,
|
||||
),
|
||||
dropdown: cn(
|
||||
"absolute inset-0 bg-popover opacity-0",
|
||||
defaultClassNames.dropdown,
|
||||
),
|
||||
caption_label: cn(
|
||||
"font-medium select-none",
|
||||
captionLayout === "label"
|
||||
? "text-sm"
|
||||
: "flex h-8 items-center gap-1 rounded-md pr-1 pl-2 text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
|
||||
defaultClassNames.caption_label,
|
||||
),
|
||||
month_grid: cn("w-full border-collapse", defaultClassNames.month_grid),
|
||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||
weekday: cn(
|
||||
"flex-1 rounded-md text-[0.8rem] font-normal text-muted-foreground select-none",
|
||||
defaultClassNames.weekday,
|
||||
),
|
||||
week: cn("mt-2 flex w-full", defaultClassNames.week),
|
||||
week_number_header: cn(
|
||||
"w-(--cell-size) select-none",
|
||||
defaultClassNames.week_number_header,
|
||||
),
|
||||
week_number: cn(
|
||||
"text-[0.8rem] text-muted-foreground select-none",
|
||||
defaultClassNames.week_number,
|
||||
),
|
||||
day: cn(
|
||||
"group/day relative aspect-square h-full w-full p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-md",
|
||||
props.showWeekNumber
|
||||
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
|
||||
: "[&:first-child[data-selected=true]_button]:rounded-l-md",
|
||||
defaultClassNames.day,
|
||||
),
|
||||
range_start: cn(
|
||||
"rounded-l-md bg-accent",
|
||||
defaultClassNames.range_start,
|
||||
),
|
||||
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
|
||||
today: cn(
|
||||
"rounded-md bg-accent text-accent-foreground data-[selected=true]:rounded-none",
|
||||
defaultClassNames.today,
|
||||
),
|
||||
outside: cn(
|
||||
"text-muted-foreground aria-selected:text-muted-foreground",
|
||||
defaultClassNames.outside,
|
||||
),
|
||||
disabled: cn(
|
||||
"text-muted-foreground opacity-50",
|
||||
defaultClassNames.disabled,
|
||||
),
|
||||
hidden: cn("invisible", defaultClassNames.hidden),
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Root: ({ className, rootRef, ...props }) => {
|
||||
return (
|
||||
<div
|
||||
data-slot="calendar"
|
||||
ref={rootRef}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
Chevron: ({ className, orientation, ...props }) => {
|
||||
if (orientation === "left") {
|
||||
return (
|
||||
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
if (orientation === "right") {
|
||||
return (
|
||||
<ChevronRightIcon
|
||||
className={cn("size-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
||||
);
|
||||
},
|
||||
DayButton: CalendarDayButton,
|
||||
WeekNumber: ({ children, ...props }) => {
|
||||
return (
|
||||
<td {...props}>
|
||||
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
||||
{children}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
},
|
||||
...components,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarDayButton({
|
||||
className,
|
||||
day,
|
||||
modifiers,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayButton>) {
|
||||
const defaultClassNames = getDefaultClassNames();
|
||||
|
||||
const ref = React.useRef<HTMLButtonElement>(null);
|
||||
React.useEffect(() => {
|
||||
if (modifiers.focused) ref.current?.focus();
|
||||
}, [modifiers.focused]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-day={day.date.toLocaleDateString()}
|
||||
data-selected-single={
|
||||
modifiers.selected &&
|
||||
!modifiers.range_start &&
|
||||
!modifiers.range_end &&
|
||||
!modifiers.range_middle
|
||||
}
|
||||
data-range-start={modifiers.range_start}
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
className={cn(
|
||||
"group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
|
||||
defaultClassNames.day,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Calendar, CalendarDayButton };
|
||||
+172
-103
@@ -1,31 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Calendar } from "lucide-react";
|
||||
import { CalendarIcon } from "lucide-react";
|
||||
import type { Matcher } from "react-day-picker";
|
||||
import { de, enUS, es, fr, it, pl } from "date-fns/locale";
|
||||
import type { Locale as DateFnsLocale } from "date-fns";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations, useDateFormatPreference } from "@/lib/i18n/context";
|
||||
import { formatDate, parseIsoDate } from "@/lib/date-format";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
|
||||
/**
|
||||
* Dependency-free date input that DISPLAYS the value in the user's date-order
|
||||
* preference (AUTO follows the locale; DMY / MDY / YMD pin the field order)
|
||||
* while editing rides the browser's native date picker.
|
||||
* while editing rides a fully app-controlled calendar — no native date picker.
|
||||
*
|
||||
* The committed VALUE is always ISO `yyyy-MM-dd` — identical to the native
|
||||
* `<input type="date">` contract `<DateInput>` ships — so this is a drop-in
|
||||
* for `<DateInput>` and existing react-hook-form wiring is unchanged.
|
||||
*
|
||||
* How it works:
|
||||
* - A visually-hidden native `<input type="date">` holds the real value and
|
||||
* emits the change events the form listens to. Tapping the field (or its
|
||||
* calendar button) calls `showPicker()` on that input, falling back to
|
||||
* `focus()` where the API is unavailable.
|
||||
* - A text overlay paints the formatted display string (or the placeholder).
|
||||
* - As a progressive enhancement the user can type a date directly: the
|
||||
* overlay is an editable text input; on blur / Enter we parse what they
|
||||
* typed against the active locale order and, on a clean parse, write the
|
||||
* ISO value back through the hidden input so the form sees a normal change.
|
||||
* - A text overlay paints the formatted display string (or the placeholder)
|
||||
* and stays editable: the user can type a date directly. On blur / Enter we
|
||||
* parse what they typed against the active locale order and, on a clean
|
||||
* parse, commit the ISO value through `commitIso`.
|
||||
* - The calendar button is a `<Popover>` trigger; the popover holds the
|
||||
* shadcn `<Calendar>` (react-day-picker). Picking a day commits the ISO
|
||||
* value and closes the popover. The calendar renders identically on every
|
||||
* browser / OS — the native picker's per-platform chrome (and its mobile
|
||||
* quirks) is gone.
|
||||
* - A hidden mirror input carries `name` + the ISO value so any caller that
|
||||
* relies on native form submission still sees a normal field; the forwarded
|
||||
* ref points at it for react-hook-form `register()` parity.
|
||||
*
|
||||
* Height / a11y / target-size parity with `<DateInput>`: the same
|
||||
* `min-h-11 h-11 sm:min-h-10 sm:h-10` floor (WCAG 2.5.5 — 44 px on mobile,
|
||||
@@ -33,7 +45,7 @@ import { formatDate, parseIsoDate } from "@/lib/date-format";
|
||||
*/
|
||||
|
||||
// Mirrors the `<DateInput>` height contract (minus the date-shadow rule, which
|
||||
// only applies to the native value text — here the native input is hidden).
|
||||
// only applies to the native value text — here no native input is rendered).
|
||||
const FIELD_HEIGHT_CLASSES = "min-h-11 h-11 sm:min-h-10 sm:h-10";
|
||||
|
||||
const FIELD_BASE_CLASSES =
|
||||
@@ -50,7 +62,7 @@ export interface DateFieldProps {
|
||||
onBlur?: () => void;
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
/** Autofill hint forwarded to the native date input (e.g. "bday"). */
|
||||
/** Autofill hint forwarded to the hidden mirror input (e.g. "bday"). */
|
||||
autoComplete?: string;
|
||||
/** ISO `yyyy-MM-dd`. */
|
||||
min?: string;
|
||||
@@ -91,16 +103,6 @@ export const DateField = React.forwardRef<HTMLInputElement, DateFieldProps>(
|
||||
const { locale, t } = useTranslations();
|
||||
const dateFormat = useDateFormatPreference();
|
||||
|
||||
const innerRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const setRefs = React.useCallback(
|
||||
(node: HTMLInputElement | null) => {
|
||||
innerRef.current = node;
|
||||
if (typeof forwardedRef === "function") forwardedRef(node);
|
||||
else if (forwardedRef) forwardedRef.current = node;
|
||||
},
|
||||
[forwardedRef],
|
||||
);
|
||||
|
||||
// Track the ISO value so the overlay reflects controlled + uncontrolled
|
||||
// updates. Controlled `value` always wins on render.
|
||||
const [internal, setInternal] = React.useState(defaultValue ?? "");
|
||||
@@ -110,30 +112,21 @@ export const DateField = React.forwardRef<HTMLInputElement, DateFieldProps>(
|
||||
// formatted string whenever the committed ISO value changes.
|
||||
const [typed, setTyped] = React.useState<string | null>(null);
|
||||
|
||||
// Calendar popover open state — selecting a day commits + closes.
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const display = formatDate(iso, dateFormat, locale);
|
||||
const overlayValue = typed ?? display;
|
||||
|
||||
const minDate = min ? isoToDate(min) : undefined;
|
||||
const maxDate = max ? isoToDate(max) : undefined;
|
||||
const selectedDate = isoToDate(iso);
|
||||
|
||||
function commitIso(next: string) {
|
||||
if (value === undefined) setInternal(next);
|
||||
const clamped = clampIso(next, min, max);
|
||||
if (value === undefined) setInternal(clamped);
|
||||
setTyped(null);
|
||||
onChange?.(next);
|
||||
}
|
||||
|
||||
function openPicker() {
|
||||
const el = innerRef.current;
|
||||
if (!el || disabled) return;
|
||||
// showPicker() throws if not user-activated or unsupported — fall back
|
||||
// to focusing the (hidden) input, which still opens the picker on most
|
||||
// mobile engines.
|
||||
try {
|
||||
(el as HTMLInputElement & { showPicker?: () => void }).showPicker?.();
|
||||
} catch {
|
||||
el.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function handleNativeChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
commitIso(e.target.value);
|
||||
onChange?.(clamped);
|
||||
}
|
||||
|
||||
// Progressive enhancement: parse free-typed text against the active locale
|
||||
@@ -156,69 +149,84 @@ export const DateField = React.forwardRef<HTMLInputElement, DateFieldProps>(
|
||||
}
|
||||
}
|
||||
|
||||
// react-day-picker gates out-of-range days; `commitIso` still clamps typed
|
||||
// entry, so the two agree on the bounds.
|
||||
const disabledMatchers: Matcher[] = [];
|
||||
if (minDate) disabledMatchers.push({ before: minDate });
|
||||
if (maxDate) disabledMatchers.push({ after: maxDate });
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(FIELD_BASE_CLASSES, FIELD_HEIGHT_CLASSES, className)}
|
||||
data-slot="date-field"
|
||||
data-disabled={disabled ? "" : undefined}
|
||||
>
|
||||
{/* Visually-hidden native date input — owns the real value + events. */}
|
||||
<input
|
||||
{...rest}
|
||||
ref={setRefs}
|
||||
id={id}
|
||||
name={name}
|
||||
type="date"
|
||||
lang={locale}
|
||||
className="sr-only"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
value={value !== undefined ? value : undefined}
|
||||
defaultValue={value === undefined ? defaultValue : undefined}
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
autoComplete={autoComplete}
|
||||
min={min}
|
||||
max={max}
|
||||
onChange={handleNativeChange}
|
||||
/>
|
||||
{/* Editable display overlay. */}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="off"
|
||||
data-testid={dataTestId}
|
||||
className="placeholder:text-muted-foreground h-full flex-1 bg-transparent py-1 outline-none disabled:cursor-not-allowed"
|
||||
value={overlayValue}
|
||||
placeholder={placeholder ?? formatPlaceholder(dateFormat, locale)}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
aria-invalid={ariaInvalid}
|
||||
onChange={(e) => setTyped(e.target.value)}
|
||||
onClick={openPicker}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
commitTyped();
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
commitTyped();
|
||||
onBlur?.();
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
disabled={disabled}
|
||||
aria-label={t("common.openDatePicker")}
|
||||
className="text-muted-foreground hover:text-foreground flex h-8 w-8 shrink-0 items-center justify-center rounded-md disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={openPicker}
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<div
|
||||
className={cn(FIELD_BASE_CLASSES, FIELD_HEIGHT_CLASSES, className)}
|
||||
data-slot="date-field"
|
||||
data-disabled={disabled ? "" : undefined}
|
||||
>
|
||||
<Calendar className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
{/* Hidden mirror — carries `name` + value for native submits and is
|
||||
the target of the forwarded ref. */}
|
||||
<input
|
||||
{...rest}
|
||||
ref={forwardedRef}
|
||||
id={id}
|
||||
name={name}
|
||||
type="hidden"
|
||||
value={iso}
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
autoComplete={autoComplete}
|
||||
readOnly
|
||||
/>
|
||||
{/* Editable display overlay. */}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="off"
|
||||
data-testid={dataTestId}
|
||||
className="placeholder:text-muted-foreground h-full flex-1 bg-transparent py-1 outline-none disabled:cursor-not-allowed"
|
||||
value={overlayValue}
|
||||
placeholder={placeholder ?? formatPlaceholder(dateFormat, locale)}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
aria-invalid={ariaInvalid}
|
||||
onChange={(e) => setTyped(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
commitTyped();
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
commitTyped();
|
||||
onBlur?.();
|
||||
}}
|
||||
/>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-label={t("common.openDatePicker")}
|
||||
className="text-muted-foreground hover:text-foreground flex h-8 w-8 shrink-0 items-center justify-center rounded-md disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<CalendarIcon className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selectedDate}
|
||||
onSelect={(d) => {
|
||||
commitIso(d ? dateToIso(d) : "");
|
||||
setOpen(false);
|
||||
}}
|
||||
disabled={disabledMatchers.length ? disabledMatchers : undefined}
|
||||
defaultMonth={selectedDate ?? new Date()}
|
||||
locale={resolveDateFnsLocale(locale)}
|
||||
autoFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -280,6 +288,67 @@ function resolveOrder(
|
||||
return locale === "en" ? "MDY" : "DMY";
|
||||
}
|
||||
|
||||
/**
|
||||
* Map the app locale → the date-fns locale so the calendar's month / weekday
|
||||
* names match the UI language. Defaults to enUS for anything unmapped.
|
||||
*/
|
||||
function resolveDateFnsLocale(
|
||||
locale: ReturnType<typeof useTranslations>["locale"],
|
||||
): DateFnsLocale {
|
||||
switch (locale) {
|
||||
case "de":
|
||||
return de;
|
||||
case "es":
|
||||
return es;
|
||||
case "fr":
|
||||
return fr;
|
||||
case "it":
|
||||
return it;
|
||||
case "pl":
|
||||
return pl;
|
||||
default:
|
||||
return enUS;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ISO `yyyy-MM-dd` → a LOCAL `Date` at midnight (no UTC shift — the value is a
|
||||
* plain calendar date, so it must read back as the same day in the user's
|
||||
* zone). Returns undefined for an empty / malformed value.
|
||||
*/
|
||||
function isoToDate(value: string): Date | undefined {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value.trim());
|
||||
if (!match) return undefined;
|
||||
const [, y, m, d] = match;
|
||||
const date = new Date(Number(y), Number(m) - 1, Number(d));
|
||||
// Reject rollover (e.g. 2026-02-31): a real date round-trips its parts.
|
||||
if (
|
||||
date.getFullYear() !== Number(y) ||
|
||||
date.getMonth() !== Number(m) - 1 ||
|
||||
date.getDate() !== Number(d)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return date;
|
||||
}
|
||||
|
||||
/** LOCAL `Date` → ISO `yyyy-MM-dd` read in the user's own zone (no UTC shift). */
|
||||
function dateToIso(date: Date): string {
|
||||
return `${pad(date.getFullYear(), 4)}-${pad(date.getMonth() + 1, 2)}-${pad(
|
||||
date.getDate(),
|
||||
2,
|
||||
)}`;
|
||||
}
|
||||
|
||||
/** Clamp an ISO `yyyy-MM-dd` string to `[min, max]` (lexical = chronological). */
|
||||
function clampIso(value: string, min?: string, max?: string): string {
|
||||
if (value === "") return value;
|
||||
let out = value;
|
||||
if (min && out < min) out = min;
|
||||
if (max && out > max) out = max;
|
||||
return out;
|
||||
}
|
||||
|
||||
function toIso(date: Date): string {
|
||||
return `${pad(date.getUTCFullYear(), 4)}-${pad(date.getUTCMonth() + 1, 2)}-${pad(
|
||||
date.getUTCDate(),
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Central form field-group primitive: one label-above-control rhythm so every
|
||||
* add form reads with the same vertical spacing. Mirrors the long-standing
|
||||
* `space-y-2` + `<Label htmlFor>` + control idiom byte-for-byte so adopting it
|
||||
* is a no-op on rendered output.
|
||||
*
|
||||
* - `hint` renders below the control as muted fine print.
|
||||
* - `labelAccessory` sits right-aligned in the label row (e.g. a char counter);
|
||||
* when present the label row becomes a `justify-between` flex row.
|
||||
*/
|
||||
export function FieldGroup({
|
||||
htmlFor,
|
||||
label,
|
||||
hint,
|
||||
labelAccessory,
|
||||
className,
|
||||
labelClassName,
|
||||
children,
|
||||
}: {
|
||||
htmlFor: string;
|
||||
label: ReactNode;
|
||||
hint?: ReactNode;
|
||||
labelAccessory?: ReactNode;
|
||||
className?: string;
|
||||
labelClassName?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("space-y-2", className)}>
|
||||
{labelAccessory ? (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label htmlFor={htmlFor} className={labelClassName}>
|
||||
{label}
|
||||
</Label>
|
||||
{labelAccessory}
|
||||
</div>
|
||||
) : (
|
||||
<Label htmlFor={htmlFor} className={labelClassName}>
|
||||
{label}
|
||||
</Label>
|
||||
)}
|
||||
{children}
|
||||
{hint ? <p className="text-muted-foreground text-xs">{hint}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,8 +17,14 @@ import { cn } from "@/lib/utils";
|
||||
* because they already pass an explicit `autoComplete` value —
|
||||
* `"username"`, `"email"`, `"current-password"`, `"new-password"`. When
|
||||
* `autoComplete` is anything other than `"off"`, the LastPass /
|
||||
* 1Password ignore attrs are dropped so password managers fill the
|
||||
* field normally.
|
||||
* 1Password / Bitwarden ignore attrs are dropped so password managers
|
||||
* fill the field normally.
|
||||
*
|
||||
* v1.25.11 — extend the cross-manager opt-out to Bitwarden
|
||||
* (`data-bwignore`) alongside the existing LastPass (`data-lpignore`)
|
||||
* and 1Password (`data-1p-ignore`) hints, so config / secret fields
|
||||
* (AI keys, bearer tokens, integration secrets, webhook URLs) are not
|
||||
* offered for autofill or saved as a login by any of the three.
|
||||
*
|
||||
* v1.4.27 mobile pass: derive a sensible `inputMode` default from the
|
||||
* `type` prop so every numeric / email / tel / url / search field
|
||||
@@ -66,6 +72,7 @@ function Input({
|
||||
inputMode={resolvedInputMode}
|
||||
data-lpignore={skipAutofill ? "true" : undefined}
|
||||
data-1p-ignore={skipAutofill ? "true" : undefined}
|
||||
data-bwignore={skipAutofill ? "true" : undefined}
|
||||
className={cn(
|
||||
// v1.4.34 IW-G — floor at 44 px on mobile to clear WCAG 2.5.5;
|
||||
// shrink to 40 px on sm+ where pointer precision is higher.
|
||||
|
||||
@@ -34,7 +34,9 @@ import { cn } from "@/lib/utils";
|
||||
* `<Input>`: HealthLog is a health-data app, and we don't want
|
||||
* a password manager pasting the user's saved password into a
|
||||
* free-text textarea that we then persist server-side. Caller
|
||||
* can opt back in (e.g. `"on"`) per field.
|
||||
* can opt back in (e.g. `"on"`) per field. When autofill is
|
||||
* skipped, the LastPass / 1Password / Bitwarden ignore hints are
|
||||
* emitted so none of the three offer to fill or save the field.
|
||||
*
|
||||
* Composition is via `cn(…)` so callers can override anything — the
|
||||
* primitive defaults are the strict pass, not the only pass.
|
||||
@@ -59,6 +61,7 @@ function Textarea({
|
||||
spellCheck={resolvedSpellCheck}
|
||||
data-lpignore={skipAutofill ? "true" : undefined}
|
||||
data-1p-ignore={skipAutofill ? "true" : undefined}
|
||||
data-bwignore={skipAutofill ? "true" : undefined}
|
||||
className={cn(
|
||||
// iOS zoom defence + WCAG 2.5.5 tap-target floor; see the
|
||||
// primitive's docblock for the full reasoning.
|
||||
|
||||
@@ -425,3 +425,34 @@ describe("lookupIpGeo carrier resolution (v1.25.8)", () => {
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveGeoProviderHost (task #162)", () => {
|
||||
it("returns the default ipwho.is host when IP_GEO_LOOKUP_URL is unset", async () => {
|
||||
const { resolveGeoProviderHost } = await import("../geo");
|
||||
expect(resolveGeoProviderHost()).toBe("ipwho.is");
|
||||
});
|
||||
|
||||
it("returns the configured provider host", async () => {
|
||||
process.env.IP_GEO_LOOKUP_URL = "http://ip-api.com/json";
|
||||
const { resolveGeoProviderHost } = await import("../geo");
|
||||
expect(resolveGeoProviderHost()).toBe("ip-api.com");
|
||||
});
|
||||
|
||||
it("preserves an explicit port in the host", async () => {
|
||||
process.env.IP_GEO_LOOKUP_URL = "https://geo.example.com:8443/lookup";
|
||||
const { resolveGeoProviderHost } = await import("../geo");
|
||||
expect(resolveGeoProviderHost()).toBe("geo.example.com:8443");
|
||||
});
|
||||
|
||||
it("falls back to the default host for a blank env value", async () => {
|
||||
process.env.IP_GEO_LOOKUP_URL = " ";
|
||||
const { resolveGeoProviderHost } = await import("../geo");
|
||||
expect(resolveGeoProviderHost()).toBe("ipwho.is");
|
||||
});
|
||||
|
||||
it("falls back to the default host for an unparseable env value", async () => {
|
||||
process.env.IP_GEO_LOOKUP_URL = "not a url";
|
||||
const { resolveGeoProviderHost } = await import("../geo");
|
||||
expect(resolveGeoProviderHost()).toBe("ipwho.is");
|
||||
});
|
||||
});
|
||||
@@ -121,6 +121,32 @@ const PRIVATE_IP =
|
||||
// online lookup misses, and skipped entirely when the databases are absent.
|
||||
const DEFAULT_GEO_URL = "https://ipwho.is";
|
||||
|
||||
// The host portion of DEFAULT_GEO_URL — the provider name the admin status
|
||||
// surface shows when the offline tier is absent and the runtime resolves
|
||||
// locations online.
|
||||
const DEFAULT_GEO_HOST = "ipwho.is";
|
||||
|
||||
/**
|
||||
* Resolve the online geo provider HOST the runtime actually queries.
|
||||
*
|
||||
* Reads `IP_GEO_LOOKUP_URL` and returns its hostname (e.g. `ip-api.com`),
|
||||
* falling back to the default `ipwho.is` when the env is unset, blank, or
|
||||
* not a parseable URL. Pure and safe — never throws.
|
||||
*
|
||||
* `/api/version` surfaces this so the admin System-Snapshot can name the
|
||||
* real provider instead of a hardcoded "ipwho.is": an operator who points
|
||||
* `IP_GEO_LOOKUP_URL` at another provider sees that provider's host.
|
||||
*/
|
||||
export function resolveGeoProviderHost(): string {
|
||||
const raw = process.env.IP_GEO_LOOKUP_URL?.trim();
|
||||
if (!raw) return DEFAULT_GEO_HOST;
|
||||
try {
|
||||
return new URL(raw).host || DEFAULT_GEO_HOST;
|
||||
} catch {
|
||||
return DEFAULT_GEO_HOST;
|
||||
}
|
||||
}
|
||||
|
||||
function geoLiteDir(): string {
|
||||
return process.env.GEOLITE2_DIR ?? "/opt/geolite2";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { formatDose } from "@/lib/medications/format-dose";
|
||||
|
||||
/**
|
||||
* The unit keys translate via `medications.wizard.steps.step3.unit.<key>`.
|
||||
* The stub mirrors a German bundle for the keys under test and echoes the
|
||||
* key for anything else so a wrong lookup is visible.
|
||||
*/
|
||||
const t = (key: string): string =>
|
||||
({
|
||||
"medications.wizard.steps.step3.unit.pieces": "Stück",
|
||||
"medications.wizard.steps.step3.unit.mg": "mg",
|
||||
"medications.wizard.steps.step3.unit.tablets": "Tablette(n)",
|
||||
})[key] ?? key;
|
||||
|
||||
describe("formatDose", () => {
|
||||
it("translates a raw unit key glued to an amount", () => {
|
||||
expect(formatDose("1 pieces", t)).toBe("1 Stück");
|
||||
});
|
||||
|
||||
it("keeps a numeric amount and translates the unit", () => {
|
||||
expect(formatDose("5 mg", t)).toBe("5 mg");
|
||||
expect(formatDose("2 tablets", t)).toBe("2 Tablette(n)");
|
||||
});
|
||||
|
||||
it("passes a custom free-text dose through unchanged", () => {
|
||||
expect(formatDose("1 puff morning", t)).toBe("1 puff morning");
|
||||
});
|
||||
|
||||
it("returns an empty string for empty / null / undefined input", () => {
|
||||
expect(formatDose("", t)).toBe("");
|
||||
expect(formatDose(null, t)).toBe("");
|
||||
expect(formatDose(undefined, t)).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Canonical dose-unit key list — the single source of truth shared by the
|
||||
* wizard's dose step (the unit `<Select>`) and the display-time
|
||||
* {@link formatDose} helper. Keep both in lock-step by importing from here;
|
||||
* never re-declare the array.
|
||||
*
|
||||
* Ordered so mg / ml / IE / µg lead and the device-form units
|
||||
* (Tablette, Hub, Sprühstoß, …) follow. Mirrors the v1.5.3 list with the
|
||||
* maintainer-requested order tightening.
|
||||
*
|
||||
* Each key resolves to a localised label at
|
||||
* `medications.wizard.steps.step3.unit.<key>` in every locale bundle.
|
||||
*/
|
||||
export const DOSE_UNIT_KEYS = [
|
||||
"mg",
|
||||
"ml",
|
||||
"iu",
|
||||
"mcg",
|
||||
"g",
|
||||
"tablets",
|
||||
"capsules",
|
||||
"drops",
|
||||
"puffs",
|
||||
"sprays",
|
||||
"pieces",
|
||||
"other",
|
||||
] as const;
|
||||
@@ -0,0 +1,45 @@
|
||||
import { DOSE_UNIT_KEYS } from "./dose-units";
|
||||
|
||||
/**
|
||||
* Display-time dose formatter.
|
||||
*
|
||||
* The wizard composes `Medication.dose` as a free-text string
|
||||
* `"{amount} {unitKey}"` storing the RAW unit key (e.g. "1 pieces",
|
||||
* "5 mg"). Rendered verbatim that leaks the untranslated English key onto
|
||||
* the card — "Test ABC 1 pieces" even on a German UI. This helper splits
|
||||
* the amount from the unit and, when the unit is one of the canonical
|
||||
* {@link DOSE_UNIT_KEYS}, swaps it for the localised label
|
||||
* (`medications.wizard.steps.step3.unit.<key>`). A custom free-text dose
|
||||
* the wizard never produced (e.g. "1 puff morning") passes through
|
||||
* unchanged.
|
||||
*
|
||||
* Pure + SSR-safe: takes the `t` accessor as a parameter, no React hook.
|
||||
*/
|
||||
|
||||
// Mirrors `parseDoseExpression` in wizard-payload.ts — split on a leading
|
||||
// number ("5 mg" → {amount:"5", unit:"mg"}); no number → {amount:"", unit:raw}.
|
||||
const DOSE_EXPR_RE = /^\s*(\d+(?:[.,]\d+)?)\s*(.*)$/;
|
||||
|
||||
const KNOWN_UNITS = new Set<string>(DOSE_UNIT_KEYS);
|
||||
|
||||
export function formatDose(
|
||||
raw: string | null | undefined,
|
||||
t: (key: string) => string,
|
||||
): string {
|
||||
if (!raw) return "";
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return "";
|
||||
|
||||
const match = DOSE_EXPR_RE.exec(trimmed);
|
||||
const amount = match ? (match[1] ?? "") : "";
|
||||
const unit = match ? (match[2] ?? "").trim() : trimmed;
|
||||
|
||||
const key = unit.toLowerCase();
|
||||
if (KNOWN_UNITS.has(key)) {
|
||||
const label = t(`medications.wizard.steps.step3.unit.${key}`);
|
||||
return amount ? `${amount} ${label}` : label;
|
||||
}
|
||||
|
||||
// Custom free-text dose (the wizard never minted this unit) — leave it be.
|
||||
return raw;
|
||||
}
|
||||
Reference in new issue
Block a user