diff --git a/CHANGELOG.md b/CHANGELOG.md index 95c74f564..be7b42350 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index dc7efeac6..19f3154e8 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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. diff --git a/messages/de.json b/messages/de.json index e25fd4009..4438719f7 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index ab971937a..11fdc9ee9 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 61dae0946..c498e22e8 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 1bdaa1ba6..8cba119f6 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 35ad15289..88a6e34fb 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index f5d479989..3b16a27c3 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/next.config.ts b/next.config.ts index ce04e9e87..92a373f73 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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/`. 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 diff --git a/package.json b/package.json index f897c5180..e419dec47 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 727372991..94a282d5a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/public/sw.js b/public/sw.js index 8fc44fb8b..ea3ee2d4d 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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/*` diff --git a/src/app/api/version/__tests__/route.test.ts b/src/app/api/version/__tests__/route.test.ts index d9138e901..ec988a145 100644 --- a/src/app/api/version/__tests__/route.test.ts +++ b/src/app/api/version/__tests__/route.test.ts @@ -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)(); + const body = (await response.json()) as VersionEnvelope; + expect(body.data.geoProviderHost).toBe("ip-api.com"); + }); }); diff --git a/src/app/api/version/route.ts b/src/app/api/version/route.ts index a69981a7d..01177527e 100644 --- a/src/app/api/version/route.ts +++ b/src/app/api/version/route.ts @@ -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, }); }); diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index c65baad7b..b59cf7ee8 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -570,7 +570,9 @@ export default function InsightsPage() { description={t("insights.editMode.emptyDescription")} action={ } /> diff --git a/src/app/labs/page.tsx b/src/app/labs/page.tsx index b10436be5..3397db3a3 100644 --- a/src/app/labs/page.tsx +++ b/src/app/labs/page.tsx @@ -90,7 +90,7 @@ export default function LabsPage() { className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9" > diff --git a/src/app/medications/__tests__/list-view-toggle.test.tsx b/src/app/medications/__tests__/list-view-toggle.test.tsx index f1c2a45ca..db9dd60c7 100644 --- a/src/app/medications/__tests__/list-view-toggle.test.tsx +++ b/src/app/medications/__tests__/list-view-toggle.test.tsx @@ -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"'); diff --git a/src/app/medications/page.tsx b/src/app/medications/page.tsx index a65d0182e..bc2121edf 100644 --- a/src/app/medications/page.tsx +++ b/src/app/medications/page.tsx @@ -370,7 +370,7 @@ export default function MedicationsPage() { className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9" > diff --git a/src/app/mood/page.tsx b/src/app/mood/page.tsx index 645b183f5..9f63ea3c6 100644 --- a/src/app/mood/page.tsx +++ b/src/app/mood/page.tsx @@ -90,7 +90,7 @@ export default function MoodPage() { className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9" > diff --git a/src/app/settings/layout/[module]/page.tsx b/src/app/settings/layout/[module]/page.tsx new file mode 100644 index 000000000..241ec4c51 --- /dev/null +++ b/src/app/settings/layout/[module]/page.tsx @@ -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/` 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/` 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 ( + + ), + }} + > +
+ + + +
+
+ ); +} diff --git a/src/components/admin/_shared.tsx b/src/components/admin/_shared.tsx index 8f20921a2..52bc60302 100644 --- a/src/components/admin/_shared.tsx +++ b/src/components/admin/_shared.tsx @@ -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 diff --git a/src/components/admin/glitchtip-section.tsx b/src/components/admin/glitchtip-section.tsx index 2640f58e8..c14a18cc8 100644 --- a/src/components/admin/glitchtip-section.tsx +++ b/src/components/admin/glitchtip-section.tsx @@ -102,6 +102,7 @@ export function GlitchtipSection() { spellCheck={false} data-lpignore="true" data-1p-ignore="true" + data-bwignore="true" disabled={updateSettings.isPending} /> @@ -121,6 +122,7 @@ export function GlitchtipSection() { spellCheck={false} data-lpignore="true" data-1p-ignore="true" + data-bwignore="true" disabled={updateSettings.isPending} /> diff --git a/src/components/admin/login-overview-section.tsx b/src/components/admin/login-overview-section.tsx index 41d6e1ae4..54a6059fc 100644 --- a/src/components/admin/login-overview-section.tsx +++ b/src/components/admin/login-overview-section.tsx @@ -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(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() { - {entry.ipAddress ?? "—"} + {entry.ipAddress ? ( + + ) : ( + "—" + )} {entry.location ?? "—"} diff --git a/src/components/admin/system-status-section.tsx b/src/components/admin/system-status-section.tsx index 3d7302146..e14543a84 100644 --- a/src/components/admin/system-status-section.tsx +++ b/src/components/admin/system-status-section.tsx @@ -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 diff --git a/src/components/admin/system-status-summary.tsx b/src/components/admin/system-status-summary.tsx index 2a3e53a53..72583f105 100644 --- a/src/components/admin/system-status-summary.tsx +++ b/src/components/admin/system-status-summary.tsx @@ -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 diff --git a/src/components/admin/umami-section.tsx b/src/components/admin/umami-section.tsx index 27f7954a7..6a92dd0a8 100644 --- a/src/components/admin/umami-section.tsx +++ b/src/components/admin/umami-section.tsx @@ -105,6 +105,7 @@ export function UmamiSection() { spellCheck={false} data-lpignore="true" data-1p-ignore="true" + data-bwignore="true" disabled={updateSettings.isPending} /> @@ -122,6 +123,7 @@ export function UmamiSection() { spellCheck={false} data-lpignore="true" data-1p-ignore="true" + data-bwignore="true" disabled={updateSettings.isPending} /> diff --git a/src/components/admin/web-push-vapid-section.tsx b/src/components/admin/web-push-vapid-section.tsx index d67d49bc6..16092aa65 100644 --- a/src/components/admin/web-push-vapid-section.tsx +++ b/src/components/admin/web-push-vapid-section.tsx @@ -144,6 +144,7 @@ export function WebPushVapidSection() { spellCheck={false} data-lpignore="true" data-1p-ignore="true" + data-bwignore="true" disabled={updateSettings.isPending} /> @@ -163,6 +164,7 @@ export function WebPushVapidSection() { spellCheck={false} data-lpignore="true" data-1p-ignore="true" + data-bwignore="true" disabled={updateSettings.isPending} /> @@ -182,6 +184,7 @@ export function WebPushVapidSection() { spellCheck={false} data-lpignore="true" data-1p-ignore="true" + data-bwignore="true" disabled={updateSettings.isPending} /> diff --git a/src/components/custom-metrics/custom-metric-list.tsx b/src/components/custom-metrics/custom-metric-list.tsx index efd023028..e9492c6df 100644 --- a/src/components/custom-metrics/custom-metric-list.tsx +++ b/src/components/custom-metrics/custom-metric-list.tsx @@ -47,7 +47,10 @@ export function CustomMetricList() { } return ( -
+

@@ -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")} > {t("customMetrics.add")} @@ -86,7 +90,11 @@ export function CustomMetricList() {

{t("customMetrics.emptyDescription")}

- diff --git a/src/components/dashboard/dashboard-header.tsx b/src/components/dashboard/dashboard-header.tsx index 79aa15331..72d0b7540 100644 --- a/src/components/dashboard/dashboard-header.tsx +++ b/src/components/dashboard/dashboard-header.tsx @@ -110,7 +110,7 @@ export function DashboardHeader({ data-testid="dashboard-customize-shortcut" > diff --git a/src/components/illness/illness-view.tsx b/src/components/illness/illness-view.tsx index a6559cc06..8f8d16772 100644 --- a/src/components/illness/illness-view.tsx +++ b/src/components/illness/illness-view.tsx @@ -401,7 +401,7 @@ export function IllnessView() { className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9" > diff --git a/src/components/insights/__tests__/insights-edit-mode-manager.test.tsx b/src/components/insights/__tests__/insights-edit-mode-manager.test.tsx index b3f9c531c..f9b21c002 100644 --- a/src/components/insights/__tests__/insights-edit-mode-manager.test.tsx +++ b/src/components/insights/__tests__/insights-edit-mode-manager.test.tsx @@ -49,7 +49,9 @@ describe(" — 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"); }); diff --git a/src/components/insights/derived/vitals-dashboard.tsx b/src/components/insights/derived/vitals-dashboard.tsx index 76f0c1394..ce6169597 100644 --- a/src/components/insights/derived/vitals-dashboard.tsx +++ b/src/components/insights/derived/vitals-dashboard.tsx @@ -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) { />
1 && "sm:grid-cols-2 lg:grid-cols-3", + )} > {mobilityTiles.map((tile) => tile.node)}
diff --git a/src/components/insights/device-score-tile.tsx b/src/components/insights/device-score-tile.tsx index 178617bb1..40a10fd9c 100644 --- a/src/components/insights/device-score-tile.tsx +++ b/src/components/insights/device-score-tile.tsx @@ -121,7 +121,7 @@ export function DeviceScoreTile({ ) : null} - + {isLearning ? ( diff --git a/src/components/insights/insights-tab-strip.tsx b/src/components/insights/insights-tab-strip.tsx index 75159f92a..400a74126 100644 --- a/src/components/insights/insights-tab-strip.tsx +++ b/src/components/insights/insights-tab-strip.tsx @@ -882,7 +882,7 @@ function InsightsTabStripImpl({ point. */} {onRegenerate && ( = { function MoodCorrelationCard({ kind, data, + className, }: { kind: MetricKind; data: MoodMetricCorrelationData; + className?: string; }) { const { t } = useTranslations(); const hasResult = data.result != null && data.n >= 5; return ( - +
@@ -165,7 +171,15 @@ export function MoodCorrelationCards({ - + {/* 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. */} +
); } diff --git a/src/components/insights/mood/mood-narrative-feed.tsx b/src/components/insights/mood/mood-narrative-feed.tsx index 2e53c899d..6560ce81d 100644 --- a/src/components/insights/mood/mood-narrative-feed.tsx +++ b/src/components/insights/mood/mood-narrative-feed.tsx @@ -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.
    1 && "sm:grid-cols-2", + )} data-slot="mood-narrative-feed" aria-label={t("insights.mood.narrative.title")} > diff --git a/src/components/insights/sleep/sleep-quality-section.tsx b/src/components/insights/sleep/sleep-quality-section.tsx index 7813c07d2..b471c092f 100644 --- a/src/components/insights/sleep/sleep-quality-section.tsx +++ b/src/components/insights/sleep/sleep-quality-section.tsx @@ -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 }) {

    } /> -
    +
    1 && "sm:grid-cols-2", + )} + > {present.map((tile) => ( 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 ( -
      +
        1 && "sm:grid-cols-2", + )} + > {truncated ? (
      • diff --git a/src/components/measurement-reminders/vorsorge-section.tsx b/src/components/measurement-reminders/vorsorge-section.tsx index e43368c38..2beba41f6 100644 --- a/src/components/measurement-reminders/vorsorge-section.tsx +++ b/src/components/measurement-reminders/vorsorge-section.tsx @@ -377,7 +377,7 @@ export function VorsorgeSection({ className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9" > diff --git a/src/components/measurements/measurement-form.tsx b/src/components/measurements/measurement-form.tsx index 40b296d82..d415d1c8c 100644 --- a/src/components/measurements/measurement-form.tsx +++ b/src/components/measurements/measurement-form.tsx @@ -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 (

        -
        - + -
        + {isBpMode ? (
        @@ -463,16 +461,16 @@ export function MeasurementForm({
    ) : ( -
    - + -
    + )} {isGlucoseMode && ( -
    - + -
    + )} -
    - + - {/* 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. */} -

    - {t("measurements.timestampBackdateHint")} -

    -
    + -
    -
    - + + } + labelAccessory={ {notes.length}/{MAX_COMMENT_LENGTH} -
    + } + > -
    + {error && (
    — 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(" — 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 - // `` 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 + // `` primitive), localised via `formatDose`. + expect(html).toContain('data-slot="medication-card-header-dose"'); }); it("shows last + next injection labels with localised weekday", () => { diff --git a/src/components/medications/__tests__/medication-card-header.test.tsx b/src/components/medications/__tests__/medication-card-header.test.tsx index f38b5ff0d..0cb7a17d5 100644 --- a/src/components/medications/__tests__/medication-card-header.test.tsx +++ b/src/components/medications/__tests__/medication-card-header.test.tsx @@ -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("", () => { - 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( ", () => { 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( + , + ); + 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("", () => { 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", () => { diff --git a/src/components/medications/detail/medication-detail-tabs.tsx b/src/components/medications/detail/medication-detail-tabs.tsx index 8486e0324..83a7240ff 100644 --- a/src/components/medications/detail/medication-detail-tabs.tsx +++ b/src/components/medications/detail/medication-detail-tabs.tsx @@ -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. */} {" "} - — {schedule.dose} + — {formatDose(schedule.dose, t)} )} @@ -483,7 +484,7 @@ export function Glp1MedicationCard({ return ( {medications.map((m) => ( ))} diff --git a/src/components/medications/medication-card-header.tsx b/src/components/medications/medication-card-header.tsx index f3713df2a..b0959696b 100644 --- a/src/components/medications/medication-card-header.tsx +++ b/src/components/medications/medication-card-header.tsx @@ -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 = ( <> - - {name} {dose} - + {name} {nameChip} + {/* 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 ? ( +

    + {dose} +

    + ) : 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 diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index 3f5aff828..4bdae4b36 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -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 && ( {" "} - — {s.dose} + — {formatDose(s.dose, t)} )} @@ -462,7 +463,7 @@ export function MedicationCard({ return ( - {medication.dose} + {formatDose(medication.dose, t)} {!medication.active && <> · {labels.inactive}} diff --git a/src/components/medications/medication-table.tsx b/src/components/medications/medication-table.tsx index dd97b5c5e..1ae984b71 100644 --- a/src/components/medications/medication-table.tsx +++ b/src/components/medications/medication-table.tsx @@ -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} - {medication.dose} + {formatDose(medication.dose, t)} {!medication.active && <> · {t("common.inactive")}} diff --git a/src/components/medications/take-all-due-dialog.tsx b/src/components/medications/take-all-due-dialog.tsx index 78b5558ae..7a2913d31 100644 --- a/src/components/medications/take-all-due-dialog.tsx +++ b/src/components/medications/take-all-due-dialog.tsx @@ -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({ {med.name} {med.dose ? ( - — {med.dose} + + {" "} + — {formatDose(med.dose, t)} + ) : null} {med.window ? ( diff --git a/src/components/medications/wizard/steps/step3-dose.tsx b/src/components/medications/wizard/steps/step3-dose.tsx index 85bc82012..4c4ed7683 100644 --- a/src/components/medications/wizard/steps/step3-dose.tsx +++ b/src/components/medications/wizard/steps/step3-dose.tsx @@ -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", diff --git a/src/components/mental-health/instrument-card.tsx b/src/components/mental-health/instrument-card.tsx index be9204a70..2b24cf69b 100644 --- a/src/components/mental-health/instrument-card.tsx +++ b/src/components/mental-health/instrument-card.tsx @@ -36,9 +36,9 @@ export function InstrumentCard({ return ( - +
    - + {t(`mentalHealth.instrument.${key}`)} diff --git a/src/components/mood/mood-form.tsx b/src/components/mood/mood-form.tsx index 959e47ea5..ce8d0e536 100644 --- a/src/components/mood/mood-form.tsx +++ b/src/components/mood/mood-form.tsx @@ -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) {
    {/* v1.8.5 (C1) — free-text note. */} -
    -
    - - {/* 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. */ -
    + } + >