Merge pull request #370 from MBombeck/release/v1.22.1

v1.22.1 — Mobile and Coach polish
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-06-27 18:46:21 +02:00
commit bed879500f
27 files changed
+887 -270

No files matched your search

+24
View File
@@ -2,6 +2,30 @@
## [Unreleased]
## [1.22.1] — 2026-06-27 — Mobile and Coach polish
A reliability and quality-of-life patch focused on the mobile interface and the Coach.
### Added
- Each lab biomarker shows a short description under its heading, the way Insights describes its metrics.
- Hover a Coach message for an action row: copy it, read it aloud, rate the reply, regenerate it, and see the time it was sent.
### Changed
- Read-aloud now selects a natural device voice instead of the default robotic one.
- The Coach prompt stays on one line and the conversation search field spans the full width.
- The settings and admin section navigation stays fixed while only the content scrolls, on both desktop and mobile.
- The admin "Module availability" section is now simply "Modules".
### Fixed
- The settings and admin side navigation no longer drifts while scrolling.
- Selecting a settings section on mobile no longer animates a scroll from the top of the page.
- The profile menu rows are evenly spaced, and the "More" menu labels are no longer clipped on narrow screens.
- Charts no longer crush the layout in landscape on small screens.
- Sleep insights no longer repeats the nightly average above its tiles.
## [1.22.0] — 2026-06-27 — A Coach that speaks, remembers, and charts; richer narratives across the app
A feature release. The Coach reads its replies aloud, can draw the metric it is discussing, remembers what you ask it to and resurfaces those reminders, and proposes checkups or measurement reminders you confirm inline. Insights and Coach assessments are rewritten for connected, motivating narratives instead of restated figures, and the daily read now folds in labs, preventive care, workouts, and glucose. Four additive migrations (`0195` hidden biomarkers, `0196` coach reminders, `0197` coach plan outcomes, `0198` AI settings); applied automatically on start — no operator action required.
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.22.0
version: 1.22.1
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
+33 -4
View File
@@ -2094,12 +2094,15 @@
"send": "Senden",
"stop": "Stopp",
"thinking": "Denke nach…",
"composerPlaceholder": "Frag mich etwas zu deinen Daten…",
"composerPlaceholder": "Frag mich etwas …",
"nudgeBubbleLabel": "Neue Nachricht vom Coach — Chat öffnen",
"fabLabel": "Coach öffnen",
"readAloud": "Vorlesen",
"readAloudStop": "Vorlesen beenden",
"messageTimeLabel": "Nachricht gesendet {time}",
"copyMessage": "Kopieren",
"copyMessageError": "Kopieren fehlgeschlagen",
"regenerate": "Erneut versuchen",
"selfContextAdopt": {
"offer": "Antwort in deine Selbstauskunft übernehmen?",
"confirm": "Übernehmen",
@@ -2614,8 +2617,6 @@
"title": "Schlaf",
"description": "Phasen-Aufschlüsselung und nächtliche Dauer – gezogen aus Apple Health.",
"headlineTitle": "Durchschnitt pro Nacht",
"headlineCaption": "Gemittelt über {count} Nächte – {suffix}",
"headlineCaptionSuffix": "pro Nacht",
"compositionTitle": "Phasen-Verteilung",
"compositionSubtitle": "Letzte {nights} Nächte",
"compositionAriaLabel": "Schlafphasen-Verteilung über {nights} Nächte",
@@ -5518,7 +5519,7 @@
"subtitle": "Version, Lizenz und Update-Status."
},
"module-availability": {
"title": "Modulverfügbarkeit",
"title": "Module",
"subtitle": "Module für jedes Konto auf diesem Server aktivieren oder deaktivieren."
}
},
@@ -6842,6 +6843,34 @@
"liver": "Leber",
"electrolytes": "Elektrolyte",
"bloodCount": "Blutbild"
},
"desc": {
"total-cholesterol": "Gesamtmenge des im Blut transportierten Cholesterins, Summe aus LDL-, HDL- und weiteren Lipoproteinfraktionen.",
"ldl": "Cholesterin in Lipoproteinen geringer Dichte, der Anteil, der sich in den Arterienwänden ablagert.",
"hdl": "Cholesterin in Lipoproteinen hoher Dichte, die Cholesterin zurück zur Leber transportieren.",
"triglycerides": "Das wichtigste Blutfett, aus der Nahrung aufgenommen und in der Leber gebildet.",
"fasting-glucose": "Nüchtern gemessener Blutzucker, der die grundlegende Glukoseregulation widerspiegelt.",
"hba1c": "Anteil des an Glukose gebundenen Hämoglobins, Spiegel des Blutzuckers der letzten zwei bis drei Monate.",
"tsh": "Hypophysenhormon, das die Schilddrüse steuert; der wichtigste Suchtest der Schilddrüsenfunktion.",
"ft3": "Die freie, aktive Form des Trijodthyronins, des stärker wirksamen Schilddrüsenhormons.",
"ft4": "Die freie Form des Thyroxins, des Haupthormons, das die Schilddrüse ins Blut abgibt.",
"ferritin": "Das wichtigste Eisenspeicherprotein des Körpers, Maß für die Eisenreserven.",
"transferrin-saturation": "Anteil des Eisentransportproteins Transferrin, der aktuell Eisen trägt.",
"vitamin-d": "Zirkulierendes 25-Hydroxy-Vitamin-D, der Standardmarker für den Vitamin-D-Status.",
"vitamin-b12": "Vitamin, das für die Bildung roter Blutkörperchen und die Nervenfunktion notwendig ist.",
"folate": "B-Vitamin, das für die Bildung roter Blutkörperchen und die DNA-Synthese benötigt wird.",
"hs-crp": "Ein Leberprotein, das bei Entzündungen ansteigt, hochempfindlich gemessen.",
"creatinine": "Abbauprodukt der Muskulatur, das über die Nieren ausgeschieden wird; Maß der Nierenfunktion.",
"egfr": "Geschätzte Filtrationsrate der Nieren, aus dem Kreatinin berechnet.",
"alt": "Leberenzym, das bei Schädigung der Leberzellen ins Blut freigesetzt wird.",
"ast": "Enzym aus Leber und Muskulatur, das bei Schädigung dieser Gewebe ansteigt.",
"ggt": "Enzym aus Leber und Gallenwegen, das bei Gallenstau oder Alkoholbelastung ansteigt.",
"sodium": "Wichtigster Elektrolyt für den Flüssigkeitshaushalt und das Blutvolumen.",
"potassium": "Elektrolyt, der für die Nervenleitung und die Herzmuskelfunktion unentbehrlich ist.",
"hemoglobin": "Das sauerstofftransportierende Protein in den roten Blutkörperchen.",
"hematocrit": "Der Anteil der roten Blutkörperchen am Blutvolumen.",
"wbc": "Gesamtzahl der weißen Blutkörperchen, der zirkulierenden Abwehr des Immunsystems.",
"platelets": "Zellfragmente, die sich zusammenlagern und die Blutgerinnung bewirken."
}
},
"noteLoadFailed": "Notiz konnte nicht geladen werden; sie bleibt beim Speichern unverändert.",
+33 -4
View File
@@ -2094,12 +2094,15 @@
"send": "Send",
"stop": "Stop",
"thinking": "Thinking…",
"composerPlaceholder": "Ask anything about your data…",
"composerPlaceholder": "Ask me anything …",
"nudgeBubbleLabel": "New message from the Coach — open chat",
"fabLabel": "Open the Coach",
"readAloud": "Read aloud",
"readAloudStop": "Stop reading",
"messageTimeLabel": "Message sent {time}",
"copyMessage": "Copy",
"copyMessageError": "Could not copy",
"regenerate": "Try again",
"selfContextAdopt": {
"offer": "Add this answer to your self-context?",
"confirm": "Add",
@@ -2614,8 +2617,6 @@
"title": "Sleep",
"description": "Stage breakdown and nightly duration, drawn from Apple Health.",
"headlineTitle": "Average per night",
"headlineCaption": "Averaged across {count} nights — {suffix}",
"headlineCaptionSuffix": "per night",
"compositionTitle": "Stage composition",
"compositionSubtitle": "Last {nights} nights",
"compositionAriaLabel": "Sleep stage composition over {nights} nights",
@@ -5518,7 +5519,7 @@
"subtitle": "Version, license, and update status."
},
"module-availability": {
"title": "Module availability",
"title": "Modules",
"subtitle": "Turn modules on or off for every account on this server."
}
},
@@ -6842,6 +6843,34 @@
"liver": "Liver",
"electrolytes": "Electrolytes",
"bloodCount": "Blood count"
},
"desc": {
"total-cholesterol": "Total amount of cholesterol carried in the blood, summing the LDL, HDL, and other lipoprotein fractions.",
"ldl": "Cholesterol carried by low-density lipoproteins, the fraction that deposits in artery walls.",
"hdl": "Cholesterol carried by high-density lipoproteins, which transport cholesterol back to the liver.",
"triglycerides": "The main fat circulating in the blood, drawn from diet and produced by the liver.",
"fasting-glucose": "Blood sugar measured after an overnight fast, reflecting baseline glucose regulation.",
"hba1c": "The share of haemoglobin bound to glucose, reflecting average blood sugar over the past two to three months.",
"tsh": "Pituitary hormone that signals the thyroid; the primary screen of thyroid function.",
"ft3": "The unbound, active form of triiodothyronine, the more potent thyroid hormone.",
"ft4": "The unbound form of thyroxine, the main hormone the thyroid releases into the blood.",
"ferritin": "The body's main iron-storage protein, reflecting total iron reserves.",
"transferrin-saturation": "Share of the iron-transport protein transferrin that is currently carrying iron.",
"vitamin-d": "Circulating 25-hydroxyvitamin D, the standard marker of vitamin D status.",
"vitamin-b12": "Vitamin essential for red blood cell formation and nerve function.",
"folate": "B-vitamin needed for red blood cell formation and DNA synthesis.",
"hs-crp": "A liver protein that rises with inflammation, measured at high sensitivity.",
"creatinine": "Muscle waste product cleared by the kidneys, used to gauge kidney function.",
"egfr": "Estimated rate at which the kidneys filter blood, derived from creatinine.",
"alt": "Liver enzyme released into the blood when liver cells are damaged.",
"ast": "Enzyme found in liver and muscle that rises when those tissues are injured.",
"ggt": "Liver and bile-duct enzyme that rises with bile-flow obstruction or alcohol exposure.",
"sodium": "Principal electrolyte governing the body's fluid balance and blood volume.",
"potassium": "Electrolyte essential for nerve signalling and heart-muscle function.",
"hemoglobin": "The oxygen-carrying protein in red blood cells.",
"hematocrit": "The fraction of blood volume made up of red blood cells.",
"wbc": "Total count of white blood cells, the immune system's circulating defence.",
"platelets": "Cell fragments that clump together to form blood clots."
}
},
"noteLoadFailed": "Note could not be loaded; it will be kept unchanged when you save.",
+33 -4
View File
@@ -2094,12 +2094,15 @@
"send": "Enviar",
"stop": "Detener",
"thinking": "Pensando…",
"composerPlaceholder": "Pregúntame sobre tus datos…",
"composerPlaceholder": "Pregúntame algo …",
"nudgeBubbleLabel": "Nuevo mensaje del coach — abrir el chat",
"fabLabel": "Abrir el coach",
"readAloud": "Leer en voz alta",
"readAloudStop": "Detener lectura",
"messageTimeLabel": "Mensaje enviado {time}",
"copyMessage": "Copiar",
"copyMessageError": "No se pudo copiar",
"regenerate": "Reintentar",
"selfContextAdopt": {
"offer": "¿Añadir esta respuesta a tu información personal?",
"confirm": "Añadir",
@@ -2614,8 +2617,6 @@
"title": "Sueño",
"description": "Desglose por fases y duración nocturna, tomados de Apple Health.",
"headlineTitle": "Media por noche",
"headlineCaption": "Promediado sobre {count} noches — {suffix}",
"headlineCaptionSuffix": "por noche",
"compositionTitle": "Distribución de fases",
"compositionSubtitle": "Últimas {nights} noches",
"compositionAriaLabel": "Distribución de fases del sueño en {nights} noches",
@@ -5518,7 +5519,7 @@
"subtitle": "Versión, licencia y estado de actualizaciones."
},
"module-availability": {
"title": "Disponibilidad de módulos",
"title": "Módulos",
"subtitle": "Activa o desactiva módulos para todas las cuentas de este servidor."
}
},
@@ -6842,6 +6843,34 @@
"liver": "Hígado",
"electrolytes": "Electrolitos",
"bloodCount": "Hemograma"
},
"desc": {
"total-cholesterol": "Cantidad total de colesterol transportado en la sangre, suma de las fracciones LDL, HDL y otras lipoproteínas.",
"ldl": "Colesterol transportado por lipoproteínas de baja densidad, la fracción que se deposita en las paredes arteriales.",
"hdl": "Colesterol transportado por lipoproteínas de alta densidad, que lo devuelven al hígado.",
"triglycerides": "La principal grasa que circula en la sangre, procedente de la dieta y producida por el hígado.",
"fasting-glucose": "Azúcar en sangre medido en ayunas, refleja la regulación basal de la glucosa.",
"hba1c": "Proporción de hemoglobina unida a glucosa, refleja el azúcar en sangre de los últimos dos o tres meses.",
"tsh": "Hormona hipofisaria que regula la tiroides; la prueba principal de la función tiroidea.",
"ft3": "La forma libre y activa de la triyodotironina, la hormona tiroidea más potente.",
"ft4": "La forma libre de la tiroxina, la principal hormona que la tiroides libera a la sangre.",
"ferritin": "La principal proteína de almacenamiento de hierro del cuerpo, refleja las reservas de hierro.",
"transferrin-saturation": "Proporción de la proteína transportadora transferrina que lleva hierro en ese momento.",
"vitamin-d": "25-hidroxivitamina D circulante, el marcador estándar del estado de vitamina D.",
"vitamin-b12": "Vitamina esencial para la formación de glóbulos rojos y la función nerviosa.",
"folate": "Vitamina B necesaria para la formación de glóbulos rojos y la síntesis de ADN.",
"hs-crp": "Una proteína hepática que aumenta con la inflamación, medida con alta sensibilidad.",
"creatinine": "Producto de desecho muscular eliminado por los riñones, usado para evaluar la función renal.",
"egfr": "Tasa estimada de filtración de los riñones, calculada a partir de la creatinina.",
"alt": "Enzima hepática liberada a la sangre cuando se dañan las células del hígado.",
"ast": "Enzima del hígado y el músculo que aumenta cuando esos tejidos se lesionan.",
"ggt": "Enzima del hígado y las vías biliares que aumenta con la obstrucción biliar o el alcohol.",
"sodium": "Principal electrolito que regula el equilibrio de líquidos y el volumen sanguíneo.",
"potassium": "Electrolito esencial para la transmisión nerviosa y la función del músculo cardíaco.",
"hemoglobin": "La proteína de los glóbulos rojos que transporta el oxígeno.",
"hematocrit": "La fracción del volumen sanguíneo formada por glóbulos rojos.",
"wbc": "Recuento total de glóbulos blancos, la defensa circulante del sistema inmunitario.",
"platelets": "Fragmentos celulares que se agregan para formar coágulos sanguíneos."
}
},
"noteLoadFailed": "No se pudo cargar la nota; se mantendrá sin cambios al guardar.",
+33 -4
View File
@@ -2094,12 +2094,15 @@
"send": "Envoyer",
"stop": "Arrêter",
"thinking": "Réflexion en cours…",
"composerPlaceholder": "Pose-moi une question sur tes données…",
"composerPlaceholder": "Pose ta question …",
"nudgeBubbleLabel": "Nouveau message du coach — ouvrir le chat",
"fabLabel": "Ouvrir le coach",
"readAloud": "Lire à voix haute",
"readAloudStop": "Arrêter la lecture",
"messageTimeLabel": "Message envoyé {time}",
"copyMessage": "Copier",
"copyMessageError": "Copie impossible",
"regenerate": "Réessayer",
"selfContextAdopt": {
"offer": "Ajouter cette réponse à vos informations personnelles ?",
"confirm": "Ajouter",
@@ -2614,8 +2617,6 @@
"title": "Sommeil",
"description": "Répartition des phases et durée nocturne, issues d’Apple Health.",
"headlineTitle": "Moyenne par nuit",
"headlineCaption": "Moyenne sur {count} nuits — {suffix}",
"headlineCaptionSuffix": "par nuit",
"compositionTitle": "Répartition des phases",
"compositionSubtitle": "{nights} dernières nuits",
"compositionAriaLabel": "Répartition des phases de sommeil sur {nights} nuits",
@@ -5518,7 +5519,7 @@
"subtitle": "Version, licence et état des mises à jour."
},
"module-availability": {
"title": "Disponibilité des modules",
"title": "Modules",
"subtitle": "Activez ou désactivez les modules pour tous les comptes de ce serveur."
}
},
@@ -6842,6 +6843,34 @@
"liver": "Foie",
"electrolytes": "Électrolytes",
"bloodCount": "Numération sanguine"
},
"desc": {
"total-cholesterol": "Quantité totale de cholestérol transporté dans le sang, somme des fractions LDL, HDL et autres lipoprotéines.",
"ldl": "Cholestérol transporté par les lipoprotéines de basse densité, la fraction qui se dépose dans les parois artérielles.",
"hdl": "Cholestérol transporté par les lipoprotéines de haute densité, qui le ramènent vers le foie.",
"triglycerides": "La principale graisse circulant dans le sang, issue de l'alimentation et produite par le foie.",
"fasting-glucose": "Glycémie mesurée à jeun, reflétant la régulation de base du glucose.",
"hba1c": "Part de l'hémoglobine liée au glucose, reflet de la glycémie des deux à trois derniers mois.",
"tsh": "Hormone hypophysaire qui régule la thyroïde ; le principal test de la fonction thyroïdienne.",
"ft3": "La forme libre et active de la triiodothyronine, l'hormone thyroïdienne la plus puissante.",
"ft4": "La forme libre de la thyroxine, la principale hormone que la thyroïde libère dans le sang.",
"ferritin": "La principale protéine de stockage du fer, reflet des réserves en fer.",
"transferrin-saturation": "Part de la transferrine, protéine de transport du fer, qui porte du fer à un instant donné.",
"vitamin-d": "25-hydroxyvitamine D circulante, le marqueur de référence du statut en vitamine D.",
"vitamin-b12": "Vitamine essentielle à la formation des globules rouges et à la fonction nerveuse.",
"folate": "Vitamine B nécessaire à la formation des globules rouges et à la synthèse de l'ADN.",
"hs-crp": "Une protéine hépatique qui s'élève avec l'inflammation, mesurée à haute sensibilité.",
"creatinine": "Déchet musculaire éliminé par les reins, utilisé pour évaluer la fonction rénale.",
"egfr": "Débit de filtration estimé des reins, calculé à partir de la créatinine.",
"alt": "Enzyme hépatique libérée dans le sang lorsque les cellules du foie sont endommagées.",
"ast": "Enzyme du foie et du muscle qui s'élève lorsque ces tissus sont lésés.",
"ggt": "Enzyme du foie et des voies biliaires qui s'élève en cas d'obstruction biliaire ou d'alcool.",
"sodium": "Principal électrolyte régulant l'équilibre hydrique et le volume sanguin.",
"potassium": "Électrolyte essentiel à la conduction nerveuse et à la fonction du muscle cardiaque.",
"hemoglobin": "La protéine des globules rouges qui transporte l'oxygène.",
"hematocrit": "La fraction du volume sanguin constituée de globules rouges.",
"wbc": "Nombre total de globules blancs, la défense circulante du système immunitaire.",
"platelets": "Fragments cellulaires qui s'agrègent pour former des caillots sanguins."
}
},
"noteLoadFailed": "La note n’a pas pu être chargée ; elle restera inchangée à l’enregistrement.",
+33 -4
View File
@@ -2094,12 +2094,15 @@
"send": "Invia",
"stop": "Interrompi",
"thinking": "Sto pensando…",
"composerPlaceholder": "Chiedimi qualcosa sui tuoi dati…",
"composerPlaceholder": "Chiedimi pure …",
"nudgeBubbleLabel": "Nuovo messaggio dal coach — apri la chat",
"fabLabel": "Apri il coach",
"readAloud": "Leggi ad alta voce",
"readAloudStop": "Interrompi lettura",
"messageTimeLabel": "Messaggio inviato {time}",
"copyMessage": "Copia",
"copyMessageError": "Copia non riuscita",
"regenerate": "Riprova",
"selfContextAdopt": {
"offer": "Aggiungere questa risposta alle tue informazioni personali?",
"confirm": "Aggiungi",
@@ -2614,8 +2617,6 @@
"title": "Sonno",
"description": "Suddivisione per fasi e durata notturna, da Apple Health.",
"headlineTitle": "Media per notte",
"headlineCaption": "Mediato su {count} notti — {suffix}",
"headlineCaptionSuffix": "per notte",
"compositionTitle": "Distribuzione delle fasi",
"compositionSubtitle": "Ultime {nights} notti",
"compositionAriaLabel": "Distribuzione delle fasi del sonno su {nights} notti",
@@ -5518,7 +5519,7 @@
"subtitle": "Versione, licenza e stato degli aggiornamenti."
},
"module-availability": {
"title": "Disponibilità dei moduli",
"title": "Moduli",
"subtitle": "Attiva o disattiva i moduli per tutti gli account su questo server."
}
},
@@ -6842,6 +6843,34 @@
"liver": "Fegato",
"electrolytes": "Elettroliti",
"bloodCount": "Emocromo"
},
"desc": {
"total-cholesterol": "Quantità totale di colesterolo trasportato nel sangue, somma delle frazioni LDL, HDL e altre lipoproteine.",
"ldl": "Colesterolo trasportato dalle lipoproteine a bassa densità, la frazione che si deposita nelle pareti arteriose.",
"hdl": "Colesterolo trasportato dalle lipoproteine ad alta densità, che lo riportano al fegato.",
"triglycerides": "Il principale grasso circolante nel sangue, assunto con la dieta e prodotto dal fegato.",
"fasting-glucose": "Glicemia misurata a digiuno, riflette la regolazione di base del glucosio.",
"hba1c": "Quota di emoglobina legata al glucosio, riflette la glicemia degli ultimi due o tre mesi.",
"tsh": "Ormone ipofisario che regola la tiroide; il principale test della funzione tiroidea.",
"ft3": "La forma libera e attiva della triiodotironina, l'ormone tiroideo più potente.",
"ft4": "La forma libera della tiroxina, il principale ormone che la tiroide rilascia nel sangue.",
"ferritin": "La principale proteina di deposito del ferro, riflette le riserve di ferro.",
"transferrin-saturation": "Quota della transferrina, proteina di trasporto del ferro, che trasporta ferro in quel momento.",
"vitamin-d": "25-idrossivitamina D circolante, il marcatore standard dello stato di vitamina D.",
"vitamin-b12": "Vitamina essenziale per la formazione dei globuli rossi e la funzione nervosa.",
"folate": "Vitamina B necessaria per la formazione dei globuli rossi e la sintesi del DNA.",
"hs-crp": "Una proteina epatica che aumenta con l'infiammazione, misurata ad alta sensibilità.",
"creatinine": "Prodotto di scarto muscolare eliminato dai reni, usato per valutare la funzione renale.",
"egfr": "Velocità di filtrazione stimata dei reni, calcolata a partire dalla creatinina.",
"alt": "Enzima epatico rilasciato nel sangue quando le cellule del fegato sono danneggiate.",
"ast": "Enzima di fegato e muscolo che aumenta quando questi tessuti sono lesi.",
"ggt": "Enzima di fegato e vie biliari che aumenta con l'ostruzione biliare o l'alcol.",
"sodium": "Principale elettrolita che regola l'equilibrio dei liquidi e il volume del sangue.",
"potassium": "Elettrolita essenziale per la conduzione nervosa e la funzione del muscolo cardiaco.",
"hemoglobin": "La proteina dei globuli rossi che trasporta l'ossigeno.",
"hematocrit": "La frazione del volume sanguigno costituita da globuli rossi.",
"wbc": "Conteggio totale dei globuli bianchi, la difesa circolante del sistema immunitario.",
"platelets": "Frammenti cellulari che si aggregano per formare coaguli di sangue."
}
},
"noteLoadFailed": "Impossibile caricare la nota; resterà invariata al salvataggio.",
+33 -4
View File
@@ -2094,12 +2094,15 @@
"send": "Wyślij",
"stop": "Zatrzymaj",
"thinking": "Myślę…",
"composerPlaceholder": "Zapytaj o swoje dane…",
"composerPlaceholder": "Zapytaj mnie …",
"nudgeBubbleLabel": "Nowa wiadomość od trenera — otwórz czat",
"fabLabel": "Otwórz trenera",
"readAloud": "Czytaj na głos",
"readAloudStop": "Zatrzymaj czytanie",
"messageTimeLabel": "Wiadomość wysłana {time}",
"copyMessage": "Kopiuj",
"copyMessageError": "Nie udało się skopiować",
"regenerate": "Spróbuj ponownie",
"selfContextAdopt": {
"offer": "Dodać tę odpowiedź do Twoich informacji o sobie?",
"confirm": "Dodaj",
@@ -2614,8 +2617,6 @@
"title": "Sen",
"description": "Podział na fazy i nocna długość snu, dane z Apple Health.",
"headlineTitle": "Średnia na noc",
"headlineCaption": "Uśrednione z {count} nocy — {suffix}",
"headlineCaptionSuffix": "na noc",
"compositionTitle": "Rozkład faz",
"compositionSubtitle": "Ostatnie {nights} nocy",
"compositionAriaLabel": "Rozkład faz snu w {nights} nocach",
@@ -5518,7 +5519,7 @@
"subtitle": "Wersja, licencja i stan aktualizacji."
},
"module-availability": {
"title": "Dostępność modułów",
"title": "Moduły",
"subtitle": "Włącz lub wyłącz moduły dla wszystkich kont na tym serwerze."
}
},
@@ -6842,6 +6843,34 @@
"liver": "Wątroba",
"electrolytes": "Elektrolity",
"bloodCount": "Morfologia krwi"
},
"desc": {
"total-cholesterol": "Całkowita ilość cholesterolu transportowanego we krwi, suma frakcji LDL, HDL i pozostałych lipoprotein.",
"ldl": "Cholesterol transportowany przez lipoproteiny o niskiej gęstości, frakcja odkładająca się w ścianach tętnic.",
"hdl": "Cholesterol transportowany przez lipoproteiny o wysokiej gęstości, które odprowadzają go do wątroby.",
"triglycerides": "Główny tłuszcz krążący we krwi, pochodzący z diety i wytwarzany przez wątrobę.",
"fasting-glucose": "Poziom cukru we krwi mierzony na czczo, odzwierciedla podstawową regulację glukozy.",
"hba1c": "Odsetek hemoglobiny związanej z glukozą, odzwierciedla poziom cukru z ostatnich dwóch–trzech miesięcy.",
"tsh": "Hormon przysadki sterujący tarczycą; podstawowy test czynności tarczycy.",
"ft3": "Wolna, aktywna postać trijodotyroniny, silniejszego hormonu tarczycy.",
"ft4": "Wolna postać tyroksyny, głównego hormonu uwalnianego przez tarczycę do krwi.",
"ferritin": "Główne białko magazynujące żelazo, odzwierciedla zapasy żelaza w organizmie.",
"transferrin-saturation": "Odsetek transferyny, białka transportującego żelazo, które w danym momencie przenosi żelazo.",
"vitamin-d": "Krążąca 25-hydroksywitamina D, standardowy wskaźnik stanu witaminy D.",
"vitamin-b12": "Witamina niezbędna do tworzenia czerwonych krwinek i funkcji nerwów.",
"folate": "Witamina z grupy B potrzebna do tworzenia czerwonych krwinek i syntezy DNA.",
"hs-crp": "Białko wątrobowe rosnące w stanach zapalnych, mierzone z wysoką czułością.",
"creatinine": "Produkt przemiany mięśni usuwany przez nerki, służy do oceny czynności nerek.",
"egfr": "Szacowany współczynnik filtracji nerek, obliczany na podstawie kreatyniny.",
"alt": "Enzym wątrobowy uwalniany do krwi przy uszkodzeniu komórek wątroby.",
"ast": "Enzym wątroby i mięśni rosnący przy uszkodzeniu tych tkanek.",
"ggt": "Enzym wątroby i dróg żółciowych rosnący przy zastoju żółci lub spożyciu alkoholu.",
"sodium": "Główny elektrolit regulujący gospodarkę wodną i objętość krwi.",
"potassium": "Elektrolit niezbędny do przewodzenia nerwowego i pracy mięśnia sercowego.",
"hemoglobin": "Białko czerwonych krwinek transportujące tlen.",
"hematocrit": "Udział czerwonych krwinek w objętości krwi.",
"wbc": "Całkowita liczba białych krwinek, krążącej obrony układu odpornościowego.",
"platelets": "Fragmenty komórkowe, które zlepiają się, tworząc skrzepy krwi."
}
},
"noteLoadFailed": "Nie udało się wczytać notatki; pozostanie niezmieniona po zapisaniu.",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.22.0",
"version": "1.22.1",
"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",
+61 -54
View File
@@ -37,7 +37,6 @@ import {
} from "lucide-react";
import { cn } from "@/lib/utils";
import { scrollBehaviorForUser } from "@/lib/motion";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { isAdminSectionSlug, type AdminSectionSlug } from "./section-slugs";
@@ -188,16 +187,15 @@ export function AdminShell({ active, children }: AdminShellProps) {
'[aria-current="page"]',
);
if (!activeChip) return;
activeChip.scrollIntoView({
block: "nearest",
// v1.4.36 W4b — `inline: "start"` pins the active chip to the
// left edge of the scroller. `inline: "center"` over-scrolled
// and the first one or two chips were unreachable on narrow
// viewports.
inline: "start",
// v1.4.43 W5-H5 — respect `prefers-reduced-motion`.
behavior: scrollBehaviorForUser(),
});
// Adjust only the strip's own horizontal offset, instantly. The old
// `scrollIntoView` walked every scrollable ancestor and could nudge the
// document vertically, and a smooth behaviour animated the strip from a
// reset `scrollLeft: 0` to the target on every tap — an unwanted "scroll
// from the start" sweep. `scrollTo({ left, behavior: "auto" })` confines
// the motion to the strip's horizontal axis and jumps without animation.
const maxScroll = strip.scrollWidth - strip.clientWidth;
const target = Math.max(0, Math.min(activeChip.offsetLeft, maxScroll));
strip.scrollTo({ left: target, behavior: "auto" });
}, [activeSlug]);
// The section bodies are already role-gated, but the shell frame
@@ -253,7 +251,7 @@ export function AdminShell({ active, children }: AdminShellProps) {
<nav
ref={mobileStripRef}
aria-label={t("admin.shell.sectionsNav")}
className="no-scrollbar -mx-4 mb-4 snap-x snap-mandatory overflow-x-auto px-4 md:hidden"
className="no-scrollbar relative -mx-4 mb-4 snap-x snap-mandatory overflow-x-auto px-4 md:hidden"
>
<ul className="flex min-w-max gap-2">
<li className="snap-start">
@@ -311,51 +309,60 @@ export function AdminShell({ active, children }: AdminShellProps) {
{headingBlock}
</div>
{/* Desktop sticky sidebar — starts at the cards row (row 2). */}
{/* Desktop sticky sidebar — starts at the cards row (row 2).
The sticky lives on the `<aside>` grid item itself, with
`self-start` so it shrinks to its content instead of stretching
to the full row height. A sticky CHILD inside a stretched grid
item resolves its containing block to the stretched item, which
in some engines lets the nav scroll away with the content rather
than pin. Sticking the grid item directly — sized to content and
offset `top-6` to clear the scroll viewport's padding — keeps the
nav fixed while only the content column scrolls. `max-h` +
`overflow-y-auto` let a long section list scroll within the
pinned panel on short viewports. */}
<aside
aria-label={t("admin.shell.sectionsNav")}
className="hidden md:col-start-1 md:row-start-2 md:block"
className="no-scrollbar hidden max-h-[calc(100dvh-5.5rem)] overflow-y-auto md:sticky md:top-6 md:col-start-1 md:row-start-2 md:block md:self-start"
>
<div className="no-scrollbar sticky top-6 max-h-[calc(100dvh-5.5rem)] overflow-y-auto">
<ul className="space-y-1">
<li>
<Link
href="/admin"
aria-current={activeSlug === null ? "page" : undefined}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
activeSlug === null
? "bg-primary/10 text-primary"
: "text-foreground hover:bg-accent",
)}
>
<ScrollText className="h-4 w-4" aria-hidden="true" />
{t("admin.shell.overview")}
</Link>
</li>
{ADMIN_SECTIONS.map((section) => {
const isActive = section.slug === activeSlug;
const Icon = section.icon;
return (
<li key={section.slug}>
<Link
href={`/admin/${section.slug}`}
aria-current={isActive ? "page" : undefined}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
isActive
? "bg-primary/10 text-primary"
: "text-foreground hover:bg-accent",
)}
>
<Icon className="h-4 w-4" aria-hidden="true" />
{t(section.titleKey)}
</Link>
</li>
);
})}
</ul>
</div>
<ul className="space-y-1">
<li>
<Link
href="/admin"
aria-current={activeSlug === null ? "page" : undefined}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
activeSlug === null
? "bg-primary/10 text-primary"
: "text-foreground hover:bg-accent",
)}
>
<ScrollText className="h-4 w-4" aria-hidden="true" />
{t("admin.shell.overview")}
</Link>
</li>
{ADMIN_SECTIONS.map((section) => {
const isActive = section.slug === activeSlug;
const Icon = section.icon;
return (
<li key={section.slug}>
<Link
href={`/admin/${section.slug}`}
aria-current={isActive ? "page" : undefined}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
isActive
? "bg-primary/10 text-primary"
: "text-foreground hover:bg-accent",
)}
>
<Icon className="h-4 w-4" aria-hidden="true" />
{t(section.titleKey)}
</Link>
</li>
);
})}
</ul>
</aside>
{/* Main column (cards) — row 2 / col 2. Same
@@ -106,7 +106,7 @@ describe("<TrendsRow>", () => {
expect(slots.length).toBe(3);
});
it("pins every chart slot to h-[180px] so Recharts mounts with a known size (CLS fix)", () => {
it("pins every chart slot to a known responsive height so Recharts mounts with a size (CLS fix)", () => {
// v1.4.36 W2 — without an explicit height the Recharts
// ResponsiveContainer mounts with width=-1 height=-1 and emits a
// console warning per chart. The mood card also drifted taller
@@ -119,12 +119,17 @@ describe("<TrendsRow>", () => {
// TrendAnnotation row below — visible as text sitting on top of
// the chart. 180 px absorbs the full envelope so the three tiles
// share one chart-band baseline without overlap.
//
// v1.22.0 — the slot height became `clamp(140px,32vh,180px)` so a
// rotated phone doesn't lose half the screen to the fixed band. The
// 180 px envelope still holds on desktop + portrait (where 32vh
// clears 180 px); only short landscape viewports shrink to the floor.
const html = render(<TrendsRow />);
const slots =
html.match(/data-slot="trends-row-chart-slot"[^>]*class="[^"]*"/g) ?? [];
expect(slots.length).toBe(3);
for (const slot of slots) {
expect(slot).toMatch(/h-\[180px\]/);
expect(slot).toMatch(/h-\[clamp\(140px,32vh,180px\)\]/);
}
});
@@ -49,7 +49,7 @@ describe("<CoachInput>", () => {
const html = render(
<CoachInput value="" onChange={() => {}} onSubmit={() => {}} />,
);
expect(html).toContain("Ask anything about your data");
expect(html).toContain("Ask me anything");
expect(html).not.toContain('data-slot="coach-input-hint"');
expect(html).not.toContain("Press Enter to send");
expect(html).toContain('data-slot="coach-input-send"');
@@ -60,7 +60,7 @@ describe("<CoachInput>", () => {
<CoachInput value="" onChange={() => {}} onSubmit={() => {}} />,
"de",
);
expect(html).toContain("Frag mich etwas zu deinen Daten");
expect(html).toContain("Frag mich etwas");
});
it("disables the send button when the value is empty", () => {
@@ -0,0 +1,60 @@
import { describe, it, expect } from "vitest";
import { pickSpeechVoice, scoreSpeechVoice } from "../message-thread";
type V = { name: string; lang: string; localService?: boolean };
const asVoices = (vs: V[]) => vs as unknown as SpeechSynthesisVoice[];
describe("scoreSpeechVoice", () => {
it("rejects voices that do not match the target language", () => {
expect(scoreSpeechVoice({ name: "Samantha", lang: "en-US" }, "de-DE")).toBe(
-Infinity,
);
});
it("ranks an enhanced/premium voice above a legacy compact one", () => {
const enhanced = scoreSpeechVoice(
{ name: "Samantha (Enhanced)", lang: "en-US" },
"en-US",
);
const compact = scoreSpeechVoice(
{ name: "Albert", lang: "en-US" },
"en-US",
);
expect(enhanced).toBeGreaterThan(compact);
expect(compact).toBeLessThan(0);
});
it("rewards an exact lang-region match over a bare language match", () => {
const region = scoreSpeechVoice({ name: "Google", lang: "de-DE" }, "de-DE");
const bare = scoreSpeechVoice({ name: "Google", lang: "de-AT" }, "de-DE");
expect(region).toBeGreaterThan(bare);
});
});
describe("pickSpeechVoice", () => {
it("returns the best-scoring voice for the locale", () => {
const chosen = pickSpeechVoice(
asVoices([
{ name: "Anna", lang: "de-DE", localService: true },
{ name: "Google Deutsch", lang: "de-DE" },
{ name: "Samantha", lang: "en-US" },
]),
"de-DE",
);
expect(chosen?.name).toBe("Google Deutsch");
});
it("returns null when nothing beats a legacy default", () => {
const chosen = pickSpeechVoice(
asVoices([{ name: "Albert", lang: "en-US" }]),
"en-US",
);
expect(chosen).toBeNull();
});
it("returns null on an empty voice list (graceful fallback)", () => {
expect(pickSpeechVoice(asVoices([]), "en-US")).toBeNull();
});
});
@@ -340,6 +340,25 @@ export function CoachConversation({
}
}
// v1.22 — "Try again": regenerate an assistant reply by resubmitting the
// user turn that produced it as a FRESH turn. The composer value is left
// untouched (unlike `handleSubmit`, which clears it) so a half-typed
// follow-up survives a regenerate. The existing thread is the conversation
// context, so the new turn continues it rather than forking.
function handleRegenerate(userText: string) {
const trimmed = userText.trim();
if (!trimmed || send.isStreaming) return;
const scope =
currentConversationId === null
? launchScopeToCoachScope(launchScope)
: undefined;
void send.send({
conversationId: currentConversationId ?? undefined,
message: trimmed,
scope,
});
}
function handleNewChat() {
setCurrentConversationId(null);
setInputValue("");
@@ -618,6 +637,7 @@ export function CoachConversation({
streaming={send.streaming}
optimisticUser={send.optimisticUser}
interleaved={interleaved}
onRegenerate={handleRegenerate}
/>
</div>
{/* Docked composer — the SAME centred, capped column as the
@@ -722,6 +742,7 @@ export function CoachConversation({
streaming={send.streaming}
optimisticUser={send.optimisticUser}
interleaved={interleaved}
onRegenerate={handleRegenerate}
/>
}
composer={composerStack}
@@ -567,6 +567,11 @@ export function CoachInput({
// scroll regions). Scoped here, not in globals.css.
"[scrollbar-width:thin] [scrollbar-color:color-mix(in_srgb,var(--dracula-purple)_35%,transparent)_transparent]",
"placeholder:text-muted-foreground disabled:opacity-60",
// Keep the placeholder a single line on narrow phones: a long
// hint used to wrap to two lines inside the one-row composer and
// read as a cramped block. Clip the PLACEHOLDER pseudo only — the
// textarea value still wraps and auto-grows for real typing.
"placeholder:overflow-hidden placeholder:text-ellipsis placeholder:whitespace-nowrap",
)}
/>
{showHub ? (
@@ -100,10 +100,10 @@ export function HistoryRail({
{t("insights.coach.historyTitle")}
</h3>
)}
<div className="relative">
<div className="relative w-full">
<Search
aria-hidden="true"
className="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2"
className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2"
/>
<Input
type="search"
@@ -111,7 +111,9 @@ export function HistoryRail({
onChange={(e) => setFilter(e.target.value)}
placeholder={t("insights.coach.historySearchPlaceholder")}
data-slot="coach-history-search"
className="h-9 pl-7 text-sm"
// Full-width, comfortable field (matches the standalone
// conversations page) instead of the old thin h-9 strip.
className="h-10 w-full pl-9"
/>
</div>
<div
@@ -19,7 +19,9 @@ import {
Check,
ChevronRight,
Clock,
Copy,
Loader2,
RotateCcw,
Sparkles,
ThumbsDown,
ThumbsUp,
@@ -98,6 +100,12 @@ export interface MessageThreadProps {
* unanchored items render at the thread tail. See `placeInterleaved`.
*/
interleaved?: InterleavedThreadItem[];
/**
* v1.22 — "Try again" on an assistant turn. The thread resolves the user
* message that produced the reply and hands its text up; the surface
* resubmits it as a fresh turn. Omitted → the regenerate action is hidden.
*/
onRegenerate?: (userText: string) => void;
}
/**
@@ -383,12 +391,105 @@ function useSpeechSynthesisSupported(): boolean {
);
}
/**
* v1.22.1 — feature-detect the async Clipboard API SSR-safe, mirroring the
* Speech-Synthesis detection above. `navigator.clipboard` is `undefined` on
* plain-HTTP self-hosts (a supported insecure-context config), so the copy
* button must be absent there rather than rendering and error-toasting on tap.
* `useSyncExternalStore` returns false on the server + first client paint and
* resolves to the real capability after hydration — no hydration mismatch.
*/
function useClipboardSupported(): boolean {
return useSyncExternalStore(
() => () => {},
() =>
typeof navigator !== "undefined" &&
typeof navigator.clipboard?.writeText === "function",
() => false,
);
}
/**
* v1.22 — score a synthesis voice for a target language. Higher is better;
* a negative score marks a legacy/compact voice we would rather skip. Pure
* (no browser globals) so the ranking contract is unit-testable. Exported
* for the read-aloud voice-selection test.
*
* The default OS voice is often a legacy "compact" engine (Anna, Albert,
* Zarvox, eSpeak) that reads robotically. We prefer, in order: an Apple
* enhanced/premium or Siri voice, a Microsoft "Natural"/"Online" (Edge)
* voice, a "Google …" (Chrome) voice, and a small set of known-good names
* (Samantha). An exact lang-REGION match (de-DE/en-US) outweighs a bare
* language match. `localService` only breaks a tie. Known-bad names are
* penalised so they never win over a neutral remote voice.
*/
export function scoreSpeechVoice(
voice: { name: string; lang: string; localService?: boolean },
lang: string,
): number {
const base = lang.split("-")[0].toLowerCase();
const vlang = voice.lang.toLowerCase().replace("_", "-");
if (!vlang.startsWith(base)) return -Infinity;
const name = voice.name.toLowerCase();
let score = 0;
// Exact lang-REGION match (de-DE, en-US) over a bare language match.
if (lang.includes("-") && vlang === lang.toLowerCase()) score += 40;
// High-quality engines.
if (/\b(enhanced|premium)\b/.test(name)) score += 60;
if (name.includes("siri")) score += 55;
if (name.includes("natural") || name.includes("online")) score += 50;
if (name.startsWith("google")) score += 35;
if (name.includes("samantha")) score += 30;
// Known-bad legacy / compact engines.
if (name.includes("compact")) score -= 80;
if (/\b(albert|zarvox|espeak|anna|fred|ralph|bahh|trinoids)\b/.test(name)) {
score -= 100;
}
// Tie-breaker only: a local voice has no network latency.
if (voice.localService) score += 1;
return score;
}
/**
* v1.22 — pick the best-scoring installed voice for `lang`, or null when
* none beats a legacy/compact default (graceful fallback to the browser
* default). Pure helper, exported for the ranking test.
*/
export function pickSpeechVoice(
voices: SpeechSynthesisVoice[],
lang: string,
): SpeechSynthesisVoice | null {
let best: SpeechSynthesisVoice | null = null;
let bestScore = 0; // require a positive score to override the default
for (const v of voices) {
const s = scoreSpeechVoice(v, lang);
if (s > bestScore) {
bestScore = s;
best = v;
}
}
return best;
}
/**
* v1.22 (W5) — read the assistant prose aloud via the browser's
* `SpeechSynthesis`. Client-only, no dependency, no egress, CSP-clean. A
* single in-flight utterance: a new `speak` cancels any prior one; `toggle`
* stops while speaking. The utterance language follows the active UI locale
* so the browser picks the closest installed voice (best-effort by OS).
* stops while speaking.
*
* v1.22 — natural-voice selection. The browser default is frequently a
* legacy/compact engine that reads robotically; instead of only stamping
* `utterance.lang` we enumerate `getVoices()`, rank the candidates for the
* locale (`pickSpeechVoice`), and assign the winner. The voice list is async
* (empty on the first call, and on iOS Safari it can stay empty until the
* first user-gesture speak), so we subscribe to `voiceschanged` and also
* re-read it lazily on the first toggle. The chosen voice is cached per
* locale in a ref so the ranking runs once.
*/
function useReadAloud(): {
supported: boolean;
@@ -397,6 +498,25 @@ function useReadAloud(): {
} {
const supported = useSpeechSynthesisSupported();
const [speaking, setSpeaking] = useState(false);
// Cache the resolved voice per locale so the ranking runs once.
const voiceByLocaleRef = useRef<Map<string, SpeechSynthesisVoice | null>>(
new Map(),
);
// Pre-warm the voice list + keep it fresh: the list arrives asynchronously
// and `voiceschanged` fires when the OS finishes loading installed voices.
useEffect(() => {
if (!supported) return;
const synth = window.speechSynthesis;
const refresh = () => {
// A late-arriving list can surface a better voice than an early read;
// clear the cache so the next toggle re-ranks against the full set.
voiceByLocaleRef.current.clear();
};
synth.getVoices();
synth.addEventListener?.("voiceschanged", refresh);
return () => synth.removeEventListener?.("voiceschanged", refresh);
}, [supported]);
// Stop any in-flight utterance if the bubble unmounts (thread reset, nav).
useEffect(() => {
@@ -407,6 +527,21 @@ function useReadAloud(): {
};
}, []);
const resolveVoice = useCallback(
(synth: SpeechSynthesis, lang: string): SpeechSynthesisVoice | null => {
const cached = voiceByLocaleRef.current.get(lang);
if (cached !== undefined) return cached;
const voices = synth.getVoices();
// An empty list (first call / iOS pre-gesture) is not cached so a later
// toggle re-ranks once the OS has populated the voices.
if (voices.length === 0) return null;
const chosen = pickSpeechVoice(voices, lang);
voiceByLocaleRef.current.set(lang, chosen);
return chosen;
},
[],
);
const toggle = useCallback(
(text: string, lang: string) => {
if (!supported) return;
@@ -420,18 +555,39 @@ function useReadAloud(): {
if (!trimmed) return;
synth.cancel();
const utterance = new SpeechSynthesisUtterance(trimmed);
utterance.lang = lang;
const voice = resolveVoice(synth, lang);
if (voice) {
utterance.voice = voice;
utterance.lang = voice.lang;
} else {
utterance.lang = lang;
}
// A natural cadence: default rate, neutral pitch.
utterance.rate = 1.0;
utterance.pitch = 1.0;
utterance.onend = () => setSpeaking(false);
utterance.onerror = () => setSpeaking(false);
setSpeaking(true);
synth.speak(utterance);
},
[supported, speaking],
[supported, speaking, resolveVoice],
);
return { supported, speaking, toggle };
}
/**
* v1.22 — shared styling for the icon-only per-message action buttons. One
* tap convention across Copy / Read-aloud / feedback / Try-again: a 44px
* mobile tap floor (WCAG 2.5.5) collapsing to a compact 32px desktop target,
* muted until hover/focus, with the standard focus ring.
*/
const COACH_ICON_BUTTON = cn(
"text-muted-foreground hover:text-foreground focus-visible:ring-ring/50",
"inline-flex size-11 min-w-11 shrink-0 items-center justify-center rounded",
"outline-none focus-visible:ring-2 disabled:opacity-50 sm:size-8 sm:min-w-8",
);
/**
* v1.22 (W5) — read-aloud toggle for a settled assistant turn. Hidden when
* the browser has no Speech Synthesis support. Speaks `stripChartTokens`
@@ -452,20 +608,87 @@ function ReadAloudButton({ content }: { content: string }) {
aria-label={label}
aria-pressed={speaking}
title={label}
// Interactive bubble affordances share one tap convention:
// `min-h-11 sm:min-h-9` (44px mobile floor → 36px desktop), matching the
// feedback buttons below. Keep TTS / remember / feedback in lockstep.
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex min-h-11 items-center gap-1 rounded px-1.5 py-1 text-xs outline-none focus-visible:ring-2 sm:min-h-9"
className={COACH_ICON_BUTTON}
>
{speaking ? (
<VolumeX className="size-3" aria-hidden="true" />
<VolumeX className="size-3.5" aria-hidden="true" />
) : (
<Volume2 className="size-3" aria-hidden="true" />
<Volume2 className="size-3.5" aria-hidden="true" />
)}
</button>
);
}
/**
* v1.22 — copy a message to the clipboard. Assistant prose is copied through
* `stripChartTokens` (the same text the bubble shows) so inline chart tokens
* are never pasted; user text is verbatim. A brief check-mark + toast confirm
* the copy. Hidden when the Clipboard API is unavailable (insecure context).
*/
function CopyMessageButton({
content,
strip,
}: {
content: string;
strip: boolean;
}) {
const { t } = useTranslations();
const supported = useClipboardSupported();
const [copied, setCopied] = useState(false);
const handle = useCallback(async () => {
const text = strip ? stripChartTokens(content) : content;
try {
await navigator.clipboard.writeText(text);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch {
toast.error(t("insights.coach.copyMessageError"));
}
}, [content, strip, t]);
// Absent on insecure-context (plain-HTTP) self-hosts where the Clipboard API
// is undefined — render nothing rather than error-toast on tap.
if (!supported) return null;
const label = t("insights.coach.copyMessage");
return (
<button
type="button"
data-slot="coach-copy-message"
onClick={handle}
aria-label={label}
title={label}
className={COACH_ICON_BUTTON}
>
{copied ? (
<Check className="text-dracula-green size-3.5" aria-hidden="true" />
) : (
<Copy className="size-3.5" aria-hidden="true" />
)}
</button>
);
}
/**
* v1.22 — "Try again": re-run the user turn that produced this assistant
* reply so the user can regenerate an unsatisfying answer. The thread hands
* down the preceding user message; the surface resubmits it as a fresh turn.
*/
function TryAgainButton({ onRegenerate }: { onRegenerate: () => void }) {
const { t } = useTranslations();
const label = t("insights.coach.regenerate");
return (
<button
type="button"
data-slot="coach-try-again"
onClick={onRegenerate}
aria-label={label}
title={label}
className={COACH_ICON_BUTTON}
>
<RotateCcw className="size-3.5" aria-hidden="true" />
</button>
);
}
/**
* v1.22 (W5) — hover/tap timestamp for a persisted message bubble. A small,
* calm clock affordance; the locale-aware date + time surfaces in a tooltip
@@ -484,7 +707,7 @@ function BubbleTimestamp({
const [open, setOpen] = useState(false);
const label = formatters.dateTime(iso);
return (
<span className="relative inline-flex">
<span className="relative inline-flex shrink-0">
<button
type="button"
data-slot="coach-bubble-timestamp"
@@ -518,6 +741,7 @@ export function MessageThread({
optimisticUser,
emptyHint,
interleaved,
onRegenerate,
}: MessageThreadProps) {
const { t } = useTranslations();
const scrollerRef = useRef<HTMLDivElement | null>(null);
@@ -709,7 +933,7 @@ export function MessageThread({
COACH_SCROLLBAR,
)}
>
{messages.map((m) => {
{messages.map((m, idx) => {
// v1.4.22 W5 reconcile (Code-MED-3) — suppress the persisted
// twin during the 150ms grace window so the streaming bubble
// stays alone on slow connections.
@@ -717,6 +941,18 @@ export function MessageThread({
// v1.16.5 — a guided question renders directly above the user
// message that answered it.
const guidedBefore = placement.before.get(m.id);
// v1.22 — "Try again": resolve the user message that produced this
// assistant reply (the nearest preceding user turn) so the surface
// can resubmit it. Null when there is none → no regenerate action.
let precedingUserContent: string | null = null;
if (m.role === "assistant" && onRegenerate) {
for (let j = idx - 1; j >= 0; j--) {
if (messages[j].role === "user") {
precedingUserContent = messages[j].content;
break;
}
}
}
return (
<Fragment key={m.id}>
{guidedBefore?.node}
@@ -729,6 +965,11 @@ export function MessageThread({
tokensUsed={m.tokensUsed}
model={m.model}
createdAt={m.createdAt}
onRegenerate={
precedingUserContent !== null && onRegenerate
? () => onRegenerate(precedingUserContent as string)
: undefined
}
/>
</Fragment>
);
@@ -840,6 +1081,12 @@ interface ChatBubbleProps {
* (no persisted time yet), so those render no timestamp.
*/
createdAt?: string;
/**
* v1.22 — bound "Try again" callback for a settled assistant turn (the
* thread closes over the preceding user message). Absent on user / streaming
* / refusal turns and when the surface supplies no regenerate handler.
*/
onRegenerate?: () => void;
}
function ChatBubble({
@@ -857,6 +1104,7 @@ function ChatBubble({
tokensUsed,
model,
createdAt,
onRegenerate,
}: ChatBubbleProps) {
const { t } = useTranslations();
const { user } = useAuth();
@@ -906,7 +1154,22 @@ function ChatBubble({
is verbatim: no chart-token strip, no Learn linkify. */}
<ProseBlocks text={content} strip={false} linkify={false} />
</div>
{createdAt && <BubbleTimestamp iso={createdAt} align="end" />}
{/* v1.22 — per-message action row: Copy, then the timestamp
trailing. Muted until the bubble is hovered / focused on pointer
devices; always visible on touch (no hover to reveal it). */}
<div
data-slot="coach-bubble-actions"
className={cn(
"flex items-center justify-end gap-0.5",
"sm:[@media(hover:hover)]:opacity-0",
"sm:[@media(hover:hover)]:group-hover/user-bubble:opacity-100",
"sm:[@media(hover:hover)]:group-focus-within/user-bubble:opacity-100",
"transition-opacity duration-150 motion-reduce:transition-none",
)}
>
<CopyMessageButton content={content} strip={false} />
{createdAt && <BubbleTimestamp iso={createdAt} align="end" />}
</div>
{/* v1.16.8 — explicit remember control. Stating an allergy in
chat used to leave no durable trace unless a narrow
pattern pass happened to match the phrasing; this stores
@@ -982,7 +1245,7 @@ function ChatBubble({
return (
<div
data-slot="coach-bubble-assistant"
className="flex items-start gap-2.5"
className="group/assistant-bubble flex items-start gap-2.5"
>
<div
aria-hidden="true"
@@ -1195,18 +1458,29 @@ function ChatBubble({
model={usage?.model ?? model}
/>
)}
{/* v1.4.23 H7 — per-message thumbs feedback. Only persisted
assistant messages get the row (skipped for refusals,
errors, in-flight stream bubbles). v1.22 (W5) — the
read-aloud toggle joins the same action row on a settled turn;
the bubble timestamp trails it. */}
{/* v1.22 — per-message hover action row. Icon-only, muted until the
bubble is hovered / focused on pointer devices; always visible on
touch (no hover to reveal it). Copy + Read-aloud + Good / Bad
feedback + Try-again, with the timestamp trailing. Only settled
persisted assistant turns get the row (skipped for refusals,
errors, in-flight stream bubbles). */}
{!inProgress && !errorCode && providerType !== "refusal" && content && (
<div
data-slot="coach-bubble-actions"
className="flex items-center gap-2"
className={cn(
"flex flex-wrap items-center gap-0.5",
"sm:[@media(hover:hover)]:opacity-0",
"sm:[@media(hover:hover)]:group-hover/assistant-bubble:opacity-100",
"sm:[@media(hover:hover)]:group-focus-within/assistant-bubble:opacity-100",
"transition-opacity duration-150 motion-reduce:transition-none",
)}
>
<CopyMessageButton content={content} strip />
{!streaming && <ReadAloudButton content={content} />}
{messageId && <CoachMessageFeedback messageId={messageId} />}
{!streaming && onRegenerate && (
<TryAgainButton onRegenerate={onRegenerate} />
)}
{createdAt && <BubbleTimestamp iso={createdAt} />}
</div>
)}
@@ -1376,38 +1650,55 @@ function CoachMessageFeedback({ messageId }: CoachMessageFeedbackProps) {
},
});
// v1.22 — icon-only feedback in the per-message action row. After a rating
// lands, the chosen thumb stays visible in its confirming colour (the other
// is dropped) with an `sr-only` thanks so the signal is legible without a
// text caption breaking the icon row.
if (submittedRating) {
return (
<p
<span
data-slot="coach-message-feedback-thanks"
className="text-muted-foreground text-xs"
role="status"
className="inline-flex items-center"
>
{t("insights.coach.feedbackThanks")}
</p>
<span className="sr-only">{t("insights.coach.feedbackThanks")}</span>
{submittedRating === "helpful" ? (
<ThumbsUp className="text-success size-3.5" aria-hidden="true" />
) : (
<ThumbsDown className="text-warning size-3.5" aria-hidden="true" />
)}
</span>
);
}
const helpfulLabel = t("insights.coach.feedbackHelpful");
const unhelpfulLabel = t("insights.coach.feedbackUnhelpful");
return (
<div data-slot="coach-message-feedback" className="flex items-center gap-2">
<div
data-slot="coach-message-feedback"
className="inline-flex items-center"
>
<button
type="button"
data-slot="coach-message-feedback-helpful"
onClick={() => submit.mutate("helpful")}
disabled={submit.isPending}
className="text-muted-foreground hover:text-success focus-visible:ring-ring/50 inline-flex min-h-11 items-center gap-1 rounded px-2 py-1.5 text-xs outline-none focus-visible:ring-2 disabled:opacity-50"
aria-label={helpfulLabel}
title={helpfulLabel}
className={cn(COACH_ICON_BUTTON, "hover:text-success")}
>
<ThumbsUp className="size-3" aria-hidden="true" />
{t("insights.coach.feedbackHelpful")}
<ThumbsUp className="size-3.5" aria-hidden="true" />
</button>
<button
type="button"
data-slot="coach-message-feedback-unhelpful"
onClick={() => submit.mutate("unhelpful")}
disabled={submit.isPending}
className="text-muted-foreground hover:text-warning focus-visible:ring-ring/50 inline-flex min-h-11 items-center gap-1 rounded px-2 py-1.5 text-xs outline-none focus-visible:ring-2 disabled:opacity-50"
aria-label={unhelpfulLabel}
title={unhelpfulLabel}
className={cn(COACH_ICON_BUTTON, "hover:text-warning")}
>
<ThumbsDown className="size-3" aria-hidden="true" />
{t("insights.coach.feedbackUnhelpful")}
<ThumbsDown className="size-3.5" aria-hidden="true" />
</button>
</div>
);
@@ -56,7 +56,7 @@ export function MoodDistributionChart({
// v1.15.14 — bounded compact height (was `aspect-[3/2] min-h-[180px]`,
// which ballooned the card on a wide viewport). A fixed band keeps this a
// tidy card rather than a dominant block, and matches the weekday sibling.
<div className="h-[150px] w-full">
<div className="h-[clamp(120px,26vh,150px)] w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={data}
@@ -46,7 +46,9 @@ const MoodDistributionChart = dynamic(
})),
{
ssr: false,
loading: () => <Skeleton className="h-[150px] w-full rounded-md" />,
loading: () => (
<Skeleton className="h-[clamp(120px,26vh,150px)] w-full rounded-md" />
),
},
);
const MoodWeekdayChart = dynamic(
@@ -56,7 +58,9 @@ const MoodWeekdayChart = dynamic(
})),
{
ssr: false,
loading: () => <Skeleton className="h-[150px] w-full rounded-md" />,
loading: () => (
<Skeleton className="h-[clamp(120px,26vh,150px)] w-full rounded-md" />
),
},
);
const MoodTimeOfDayChart = dynamic(
@@ -66,7 +70,9 @@ const MoodTimeOfDayChart = dynamic(
})),
{
ssr: false,
loading: () => <Skeleton className="h-[220px] w-full rounded-md" />,
loading: () => (
<Skeleton className="h-[clamp(160px,38vh,220px)] w-full rounded-md" />
),
},
);
import { MoodTagBreakdown, type MoodTagRow } from "./mood-tag-breakdown";
@@ -78,7 +78,7 @@ export function MoodTimeOfDayChart({
width-constrained), so an aspect ratio derived the height off the full
card width and ballooned the chart to ~800px on a wide viewport. A
bounded height keeps it the same size as the sibling charts. */}
<div className="h-[220px] w-full">
<div className="h-[clamp(160px,38vh,220px)] w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={data}
@@ -77,7 +77,7 @@ export function MoodWeekdayChart({ weekday }: { weekday: MoodWeekdayRow[] }) {
which derived its height off the full card width and ballooned on a
wide viewport). A fixed band keeps it a tidy card matching the
distribution sibling. */}
<div className="h-[150px] w-full">
<div className="h-[clamp(120px,26vh,150px)] w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={data}
+10 -54
View File
@@ -9,7 +9,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Card, CardContent } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import dynamic from "next/dynamic";
import { ChartErrorBoundary } from "@/components/charts/chart-error-state";
@@ -45,13 +45,16 @@ function SleepStageStackedBar(
*
* Pulls the analytics `sleepStages` aggregate (added in v1.4.23 W2)
* plus the SLEEP_DURATION trend series via `<SleepDurationChart>` and
* renders three blocks:
* renders two blocks:
*
* 1. Headline card with the average nightly total (h / min) and the
* number of nights covered.
* 2. Stage composition stacked bar (REM / Deep / Core / Awake / In
* 1. Stage composition stacked bar (REM / Deep / Core / Awake / In
* bed / Asleep) — the canonical sleep-phase distribution.
* 3. Duration trend chart with chart-cog parity (`chartKey="sleep"`).
* 2. Duration trend chart with chart-cog parity (`chartKey="sleep"`).
*
* v1.22.0 — the leading "average per night" headline card was dropped:
* the same figure + label already renders in `<AverageSleepCard>` inside
* the shared sleep-rhythm grid row directly below, so the top card only
* duplicated it.
*
* v1.18.7 W-D — the single-night "Last night" hypnogram card was removed:
* its per-stage breakdown (time + %) duplicated the phase distribution the
@@ -80,21 +83,9 @@ interface SleepAnalyticsResponse {
sleepStages: SleepStageBreakdown | null;
}
function formatHoursMinutes(
totalMinutes: number,
locale: string,
): { primary: string } {
const hours = Math.floor(totalMinutes / 60);
const mins = Math.round(totalMinutes - hours * 60);
if (locale === "de") {
return { primary: `${hours} Std. ${mins} Min.` };
}
return { primary: `${hours}h ${mins}m` };
}
export function SleepOverview() {
const { isAuthenticated, user } = useAuth();
const { t, locale } = useTranslations();
const { t } = useTranslations();
const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated);
// v1.4.33 IW2 — sleep overview reads `sleepStages` (a thick-only
@@ -127,43 +118,8 @@ export function SleepOverview() {
);
}
const averageMinutes = sleepSummary?.avg30 ?? sleepSummary?.avg7 ?? null;
const suffix = t("insights.sleep.headlineCaptionSuffix");
const { primary } =
averageMinutes != null
? formatHoursMinutes(averageMinutes, locale)
: { primary: "—" };
return (
<div className="space-y-4">
{/* v1.12.0 — headline card tightened: the default Card
`gap-4 md:gap-6` + `py-4 md:py-6` left a tall empty band around a
two-line readout. `gap-2 py-4 md:py-4` pulls the caption up under the
number without crowding it. */}
<Card className="gap-2 py-4 md:gap-2 md:py-4">
<CardHeader className="pb-0">
<div className="flex items-center gap-2">
<Moon className="text-dracula-cyan h-4 w-4" />
<CardTitle className="text-base font-semibold">
{t("insights.sleep.headlineTitle")}
</CardTitle>
</div>
</CardHeader>
<CardContent>
<div className="space-y-0.5">
<p className="text-2xl font-semibold">{primary}</p>
<p className="text-muted-foreground text-xs">
{sleepSummary?.count
? t("insights.sleep.headlineCaption", {
count: sleepSummary.count,
suffix,
})
: suffix}
</p>
</div>
</CardContent>
</Card>
{sleepStages ? (
<SleepStageStackedBar breakdown={sleepStages} />
) : (
+8 -1
View File
@@ -277,7 +277,14 @@ export function TrendsRow({
lives entirely on this slot. */}
<div
data-slot="trends-row-chart-slot"
className="h-[180px] shrink-0 overflow-hidden [--chart-height:120px]"
// v1.22.0 — the slot height is responsive to the viewport so a
// rotated phone doesn't lose half the screen to a fixed 180 px
// band. `clamp(140px,32vh,180px)` pins the same 180 px envelope
// on desktop + portrait (where 32vh clears 180 px) and only
// shrinks toward the 140 px floor on short landscape viewports.
// `overflow-hidden` already clips any stray axis label, so the
// tighter band stays tidy.
className="h-[clamp(140px,32vh,180px)] shrink-0 overflow-hidden [--chart-height:120px]"
>
<MetricChart config={config} title={title} />
</div>
+86 -51
View File
@@ -11,6 +11,7 @@ import { Card, CardContent } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { Skeleton } from "@/components/ui/skeleton";
import { apiGet } from "@/lib/api/api-fetch";
import { BIOMARKER_CATALOG } from "@/lib/labs/biomarker-catalog";
import { formatDate } from "@/lib/format";
import { formatReferenceRange } from "@/lib/labs/reference-range";
import { formatLabReading, formatLabValue } from "@/lib/labs/format-value";
@@ -77,6 +78,26 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
const { t } = useTranslations();
const { prefs } = useModuleListPrefs("labs");
// v1.22 — a short, factual line under each marker heading describing what the
// biomarker measures, sourced from the catalog via i18n. Free-text markers
// (no catalog match) carry no subtitle rather than a fabricated one.
// v1.22.1 — build the localized name→slug map once per locale instead of
// re-scanning all catalog seeds (calling `t()` each) per group every render.
const slugByName = useMemo(() => {
const map = new Map<string, string>();
for (const seed of BIOMARKER_CATALOG) {
const norm = t(`labs.catalog.${seed.slug}`).trim().toLowerCase();
if (norm) map.set(norm, seed.slug);
}
return map;
}, [t]);
const describeMarker = (analyte: string): string | null => {
const norm = analyte.trim().toLowerCase();
if (!norm) return null;
const slug = slugByName.get(norm);
return slug ? t(`labs.catalog.desc.${slug}`) : null;
};
const listKey = queryKeys.labResultsList({
biomarkerId: undefined,
analyte: undefined,
@@ -183,6 +204,7 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
<Card>
<CardContent className="divide-border divide-y p-0">
{groups.map((group) => {
const description = describeMarker(group.analyte);
const inner = (
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-baseline gap-x-2 text-sm">
@@ -194,6 +216,11 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
compact
/>
</div>
{description ? (
<p className="text-muted-foreground text-xs">
{description}
</p>
) : null}
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 text-xs">
<span className="text-foreground font-semibold tabular-nums">
{formatLabReading(group.latest)}
@@ -250,61 +277,69 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
</p>
</li>
) : null}
{groups.map((group) => (
<li key={group.key} className="contents">
<Card className="h-full gap-3">
<MedicationCardHeader
name={group.analyte}
dose=""
categoryLabel={group.panel ?? group.unit}
nameChip={
<ReferenceRangeBadge
status={group.latest.rangeStatus}
compact
/>
}
href={
group.biomarkerId ? `/labs/${group.biomarkerId}` : undefined
}
linkLabel={group.analyte}
/>
<CardContent>
<div className="flex items-center justify-between gap-3">
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 text-sm">
<span className="text-foreground font-semibold tabular-nums">
{formatLabReading(group.latest)}
</span>
{group.latest.value !== null &&
(group.latest.referenceLow !== null ||
group.latest.referenceHigh !== null) ? (
<span className="text-xs">
{t("labs.referenceLabel")}{" "}
{formatReferenceRange(
group.latest.referenceLow,
group.latest.referenceHigh,
formatLabValue,
)}
{groups.map((group) => {
const description = describeMarker(group.analyte);
return (
<li key={group.key} className="contents">
<Card className="h-full gap-3">
<MedicationCardHeader
name={group.analyte}
dose=""
categoryLabel={group.panel ?? group.unit}
nameChip={
<ReferenceRangeBadge
status={group.latest.rangeStatus}
compact
/>
}
href={
group.biomarkerId ? `/labs/${group.biomarkerId}` : undefined
}
linkLabel={group.analyte}
/>
<CardContent>
{description ? (
<p className="text-muted-foreground mb-2 text-xs">
{description}
</p>
) : null}
<div className="flex items-center justify-between gap-3">
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 text-sm">
<span className="text-foreground font-semibold tabular-nums">
{formatLabReading(group.latest)}
</span>
) : null}
<span className="text-xs">
{formatDate(group.latest.takenAt)}
</span>
{group.readings.length > 1 ? (
{group.latest.value !== null &&
(group.latest.referenceLow !== null ||
group.latest.referenceHigh !== null) ? (
<span className="text-xs">
{t("labs.referenceLabel")}{" "}
{formatReferenceRange(
group.latest.referenceLow,
group.latest.referenceHigh,
formatLabValue,
)}
</span>
) : null}
<span className="text-xs">
{t("labs.readingsCount", {
count: group.readings.length,
})}
{formatDate(group.latest.takenAt)}
</span>
) : null}
{group.readings.length > 1 ? (
<span className="text-xs">
{t("labs.readingsCount", {
count: group.readings.length,
})}
</span>
) : null}
</div>
<LabTrendSparkline
values={group.readings.map((r) => r.value)}
/>
</div>
<LabTrendSparkline
values={group.readings.map((r) => r.value)}
/>
</div>
</CardContent>
</Card>
</li>
))}
</CardContent>
</Card>
</li>
);
})}
</ul>
);
}
+15 -5
View File
@@ -220,14 +220,19 @@ export function BottomNav() {
// The sheet auto-sizes to its content; padding-bottom respects
// the iOS safe area so the inner buttons stay reachable above
// the home-indicator on iPhone-X-class devices.
className="rounded-t-xl pb-[calc(env(safe-area-inset-bottom)+1rem)] md:hidden"
className="mx-auto max-w-md rounded-t-xl pb-[calc(env(safe-area-inset-bottom)+1rem)] md:hidden"
data-testid="bottom-nav-more-sheet"
>
<SheetHeader>
<SheetTitle>{t("nav.moreSheetTitle")}</SheetTitle>
<SheetDescription>{t("nav.moreSheetDescription")}</SheetDescription>
</SheetHeader>
<div className="grid grid-cols-2 gap-2 px-4 pb-4">
{/* v1.22.1 — tighten the grid gutters (`px-3`) so each tile claims a
little more width on a 320 px phone; the long single-word labels
(de "Benachrichtigungen") then have room to break onto a clean
second line instead of clipping the last character at the tile
edge. */}
<div className="grid grid-cols-2 gap-2 px-3 pb-4">
{moreHub.map((item) => {
const isActive = isActiveLink(item.href);
return (
@@ -239,8 +244,11 @@ export function BottomNav() {
// v1.12 — `min-h-14` matches the capture-picker tiles so
// the equivalent tappable rows share one height at this
// tier (and the larger row is a more comfortable target).
// v1.22.1 — trim the inner padding/gap (`px-3`, `gap-2.5`)
// to hand the label more horizontal room without changing
// the shared tile height.
className={cn(
"border-border flex min-h-14 items-center gap-3 rounded-lg border px-4 py-3 transition-colors",
"border-border flex min-h-14 items-center gap-2.5 rounded-lg border px-3 py-3 transition-colors",
isActive
? "text-primary bg-primary/5"
: "text-foreground hover:bg-accent/40",
@@ -252,11 +260,13 @@ export function BottomNav() {
("Benachrichtigungen" → "Benachrichtigun…"); the goal is a
readable label, not a balanced tile. Shrink the type to
`text-xs` so long single-word locales (de/es/fr/it) fit on
one line at ~110px, and allow a 2-line `line-clamp-2`
one line where they can, and allow a 2-line `line-clamp-2`
fallback (with `[overflow-wrap:anywhere]` so a long word
breaks rather than overflowing) for the rare label that
still cannot fit one line. `min-w-0` lets the flex child
shrink below its content width. */}
shrink below its content width. v1.22.1 — with the wider
text area above, "Benachrichtigungen" wraps fully inside
two lines down to a 320 px viewport. */}
<span
className="line-clamp-2 min-w-0 text-xs leading-tight font-medium [overflow-wrap:anywhere]"
title={t(item.tKey)}
+7 -1
View File
@@ -126,7 +126,13 @@ export function TopBar() {
for the small audience that still reaches it (admins
only, on the order of once or twice a year). */}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
{/* v1.22.1 — the shared sub-trigger ships `py-1.5` and no
min-height, while every sibling `DropdownMenuItem` carries
`min-h-11 py-2`. Left as-is the Theme row sits a few pixels
shorter than the rows around it and the menu reads as
unevenly spaced. Match the item height here (instance-level
override, so no other dropdown's sub-trigger is touched). */}
<DropdownMenuSubTrigger className="min-h-11 py-2">
{themeIcon}
<span className="ml-2">{t("nav.theme")}</span>
</DropdownMenuSubTrigger>
+42 -34
View File
@@ -43,7 +43,6 @@ import {
} from "lucide-react";
import { cn } from "@/lib/utils";
import { scrollBehaviorForUser } from "@/lib/motion";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import type { ModuleKey } from "@/lib/modules/registry";
@@ -366,11 +365,11 @@ export function SettingsShell({
// box, which is the origin for `scrollLeft`.
const maxScroll = strip.scrollWidth - strip.clientWidth;
const target = Math.max(0, Math.min(active.offsetLeft, maxScroll));
strip.scrollTo({
left: target,
// v1.4.43 W5-H5 — respect `prefers-reduced-motion`.
behavior: scrollBehaviorForUser(),
});
// Position the active chip instantly. A smooth behaviour here animates
// the strip from its reset `scrollLeft: 0` to the target on every tap,
// which reads as an unwanted "scroll from the start" sweep when swapping
// sections. An instant jump keeps the chip in view without the sweep.
strip.scrollTo({ left: target, behavior: "auto" });
}, [activeSlug]);
// v1.18.6.1 — resolve the heading once. Pages pass an explicit `heading`
@@ -451,7 +450,7 @@ export function SettingsShell({
<nav
ref={mobileStripRef}
aria-label={t("settings.shell.sectionsNav")}
className="no-scrollbar -mx-4 mb-4 snap-x snap-mandatory overflow-x-auto px-4 md:hidden"
className="no-scrollbar relative -mx-4 mb-4 snap-x snap-mandatory overflow-x-auto px-4 md:hidden"
>
<ul className="flex min-w-max gap-2">
{visibleSections.map((section) => {
@@ -497,36 +496,45 @@ export function SettingsShell({
</div>
) : null}
{/* Desktop sticky sidebar — starts at the cards row (row 2). */}
{/* Desktop sticky sidebar — starts at the cards row (row 2).
The sticky lives on the `<aside>` grid item itself, with
`self-start` so it shrinks to its content instead of stretching
to the full row height. A sticky CHILD inside a stretched grid
item resolves its containing block to the stretched item, which
in some engines lets the nav scroll away with the content rather
than pin. Sticking the grid item directly — sized to content and
offset `top-6` to clear the scroll viewport's padding — keeps the
nav fixed while only the content column scrolls. `max-h` +
`overflow-y-auto` let a long section list scroll within the
pinned panel on short viewports. */}
<aside
aria-label={t("settings.shell.sectionsNav")}
className="hidden md:col-start-1 md:row-start-2 md:block"
className="no-scrollbar hidden max-h-[calc(100dvh-5.5rem)] overflow-y-auto md:sticky md:top-6 md:col-start-1 md:row-start-2 md:block md:self-start"
>
<div className="no-scrollbar sticky top-6 max-h-[calc(100dvh-5.5rem)] overflow-y-auto">
<ul className="space-y-1">
{visibleSections.map((section) => {
const isActive = section.slug === activeSlug;
const Icon = section.icon;
return (
<li key={section.slug}>
<Link
href={`/settings/${section.slug}`}
aria-current={isActive ? "page" : undefined}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
isActive
? "bg-primary/10 text-primary"
: "text-foreground hover:bg-accent",
)}
>
<Icon className="h-4 w-4" aria-hidden="true" />
{t(section.titleKey)}
</Link>
</li>
);
})}
</ul>
</div>
<ul className="space-y-1">
{visibleSections.map((section) => {
const isActive = section.slug === activeSlug;
const Icon = section.icon;
return (
<li key={section.slug}>
<Link
href={`/settings/${section.slug}`}
aria-current={isActive ? "page" : undefined}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
isActive
? "bg-primary/10 text-primary"
: "text-foreground hover:bg-accent",
)}
>
<Icon className="h-4 w-4" aria-hidden="true" />
{t(section.titleKey)}
</Link>
</li>
);
})}
</ul>
</aside>
{/* Main column — page renders its own h1 + subtitle. The