From b424c37ab0216860b765ead91e8e2e7e562836f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 02:52:44 +0200 Subject: [PATCH 1/5] i18n: add keys for the UX and accessibility pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chart and heatmap text-alternative summaries, the version-update toast, the touch-help trigger label, list load-error messages, and the target-editor validation hints — across all six locales. --- messages/de.json | 38 ++++++++++++++++++++++++++++++++------ messages/en.json | 38 ++++++++++++++++++++++++++++++++------ messages/es.json | 38 ++++++++++++++++++++++++++++++++------ messages/fr.json | 38 ++++++++++++++++++++++++++++++++------ messages/it.json | 38 ++++++++++++++++++++++++++++++++------ messages/pl.json | 38 ++++++++++++++++++++++++++++++++------ 6 files changed, 192 insertions(+), 36 deletions(-) diff --git a/messages/de.json b/messages/de.json index ce28bb09c..43740d2e4 100644 --- a/messages/de.json +++ b/messages/de.json @@ -35,7 +35,8 @@ "showPassword": "Passwort anzeigen", "hidePassword": "Passwort verbergen", "learnMore": "Mehr erfahren", - "dismiss": "Schließen" + "dismiss": "Schließen", + "moreInfo": "Mehr Informationen" }, "doctorReport": { "title": "Gesundheitsbericht", @@ -750,7 +751,8 @@ "sectionFactors": "Faktoren", "sectionNoteDetails": "Notiz & Details", "quickTagsHelp": "Häufig genutzte Tags – ein Tipp genügt", - "quickTagsExpand": "Weitere" + "quickTagsExpand": "Weitere", + "loadError": "Stimmungseinträge konnten nicht geladen werden." }, "medications": { "title": "Medikamente", @@ -1852,7 +1854,20 @@ "sat": "Samstag", "sun": "Sonntag" }, - "rangeBand": "Bereich" + "rangeBand": "Bereich", + "a11y": { + "summary": "{metric}-Diagramm. Bereich {min} bis {max}, aktuell {latest}, Trend {trend}.", + "noData": "{metric}-Diagramm. Keine Daten verfügbar.", + "trendUp": "steigend", + "trendDown": "fallend", + "trendFlat": "gleichbleibend", + "scatterSummary": "Korrelationsdiagramm, {count} Punkte: {xLabel} gegen {yLabel}.", + "scatterNoData": "Korrelationsdiagramm {xLabel} gegen {yLabel}. Keine Daten verfügbar.", + "moodHeatmap": "Stimmungs-Heatmap von {start} bis {end}, {days} Tage erfasst.", + "moodHeatmapEmpty": "Stimmungs-Heatmap, noch keine Tage erfasst.", + "complianceHeatmap": "Heatmap zur Medikamenteneinnahme von {start} bis {end}, {days} aktive Tage.", + "complianceHeatmapEmpty": "Heatmap zur Medikamenteneinnahme, noch keine aktiven Tage." + } }, "chart": { "overlay": { @@ -6173,7 +6188,9 @@ "resetToDefault": "Auf Standard zurücksetzen", "saveSuccess": "Zielbereich aktualisiert", "saveError": "Zielbereich konnte nicht gespeichert werden", - "resetSuccess": "Zielbereich auf Standard zurückgesetzt" + "resetSuccess": "Zielbereich auf Standard zurückgesetzt", + "validationOrder": "Der Minimalwert muss unter dem Maximalwert liegen.", + "validationBounds": "Werte innerhalb des unten angegebenen Bereichs eingeben." }, "currentValue": "Aktuell: {value} {unit}", "targetRangeValue": "Zielbereich: {min}–{max} {unit}", @@ -6831,7 +6848,8 @@ "empty": "Keine anstehenden Vorsorge-Erinnerungen.", "openLink": "Vorsorge öffnen" }, - "showMeasurements": "Messergebnisse anzeigen" + "showMeasurements": "Messergebnisse anzeigen", + "loadError": "Erinnerungen konnten nicht geladen werden." }, "moduleList": { "viewToggleLabel": "Ansicht", @@ -7312,7 +7330,8 @@ "BLOOD_PRESSURE_DIA": "Diastolic blood pressure", "PULSE": "Pulse", "WEIGHT": "Weight" - } + }, + "listLoadError": "Episoden konnten nicht geladen werden." }, "coach": { "reminderSuggestion": { @@ -7372,5 +7391,12 @@ "glucoseHighDoctor": "Zwei aktuelle Messungen lagen bei {value} mg/dL oder höher. Miss erneut, trink ausreichend und kontaktiere möglichst bald deine Ärztin oder deinen Arzt, wenn es so hoch bleibt.", "glucoseHighEmergency": "Zwei aktuelle Messungen lagen bei {value} mg/dL oder höher und du hast Beschwerden angegeben. Bei Erbrechen, schneller Atmung oder Verwirrtheit sofort den Notruf wählen." } + }, + "version": { + "updateAvailable": { + "title": "Eine neue Version ist verfügbar", + "reload": "Neu laden", + "dismiss": "Schließen" + } } } diff --git a/messages/en.json b/messages/en.json index 14fa8b085..2ebe72db7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -35,7 +35,8 @@ "showPassword": "Show password", "hidePassword": "Hide password", "learnMore": "Learn more", - "dismiss": "Dismiss" + "dismiss": "Dismiss", + "moreInfo": "More information" }, "doctorReport": { "title": "Health Report", @@ -750,7 +751,8 @@ "sectionFactors": "Factors", "sectionNoteDetails": "Note & details", "quickTagsHelp": "Your frequent tags — one tap", - "quickTagsExpand": "More" + "quickTagsExpand": "More", + "loadError": "Could not load your mood entries." }, "medications": { "title": "Medications", @@ -1852,7 +1854,20 @@ "sat": "Saturday", "sun": "Sunday" }, - "rangeBand": "Range" + "rangeBand": "Range", + "a11y": { + "summary": "{metric} chart. Range {min} to {max}, latest {latest}, trend {trend}.", + "noData": "{metric} chart. No data available.", + "trendUp": "rising", + "trendDown": "falling", + "trendFlat": "steady", + "scatterSummary": "Correlation chart, {count} points plotting {xLabel} against {yLabel}.", + "scatterNoData": "Correlation chart of {xLabel} against {yLabel}. No data available.", + "moodHeatmap": "Mood heatmap from {start} to {end}, {days} days logged.", + "moodHeatmapEmpty": "Mood heatmap, no days logged yet.", + "complianceHeatmap": "Medication compliance heatmap from {start} to {end}, {days} active days.", + "complianceHeatmapEmpty": "Medication compliance heatmap, no active days yet." + } }, "chart": { "overlay": { @@ -6173,7 +6188,9 @@ "resetToDefault": "Reset to default", "saveSuccess": "Target range updated", "saveError": "Could not save the target range", - "resetSuccess": "Target range reset to default" + "resetSuccess": "Target range reset to default", + "validationOrder": "The minimum must be lower than the maximum.", + "validationBounds": "Enter values within the allowed range shown below." }, "currentValue": "Current: {value} {unit}", "targetRangeValue": "Target: {min}–{max} {unit}", @@ -6831,7 +6848,8 @@ "empty": "No upcoming checkup reminders.", "openLink": "Open checkups" }, - "showMeasurements": "Show measurements" + "showMeasurements": "Show measurements", + "loadError": "Could not load your reminders." }, "moduleList": { "viewToggleLabel": "View", @@ -7312,7 +7330,8 @@ "BLOOD_PRESSURE_DIA": "Diastolic blood pressure", "PULSE": "Pulse", "WEIGHT": "Weight" - } + }, + "listLoadError": "Could not load your episodes." }, "coach": { "reminderSuggestion": { @@ -7372,5 +7391,12 @@ "glucoseHighDoctor": "Two recent readings were {value} mg/dL or higher. Re-check, hydrate, and contact your doctor as soon as possible if it stays this high.", "glucoseHighEmergency": "Two recent readings were {value} mg/dL or higher and you noted symptoms. If you have vomiting, fast breathing, or confusion, call emergency services now." } + }, + "version": { + "updateAvailable": { + "title": "A new version is available", + "reload": "Reload", + "dismiss": "Dismiss" + } } } diff --git a/messages/es.json b/messages/es.json index ce2ee6de9..bbbaa6b8b 100644 --- a/messages/es.json +++ b/messages/es.json @@ -35,7 +35,8 @@ "showPassword": "Mostrar contraseña", "hidePassword": "Ocultar contraseña", "learnMore": "Más información", - "dismiss": "Descartar" + "dismiss": "Descartar", + "moreInfo": "Más información" }, "doctorReport": { "title": "Informe de salud", @@ -750,7 +751,8 @@ "sectionFactors": "Factores", "sectionNoteDetails": "Nota y detalles", "quickTagsHelp": "Tus etiquetas frecuentes: un toque", - "quickTagsExpand": "Más" + "quickTagsExpand": "Más", + "loadError": "No se pudieron cargar tus registros de estado de ánimo." }, "medications": { "title": "Medicamentos", @@ -1852,7 +1854,20 @@ "sat": "Sábado", "sun": "Domingo" }, - "rangeBand": "Rango" + "rangeBand": "Rango", + "a11y": { + "summary": "Gráfico de {metric}. Rango de {min} a {max}, último {latest}, tendencia {trend}.", + "noData": "Gráfico de {metric}. No hay datos disponibles.", + "trendUp": "en aumento", + "trendDown": "en descenso", + "trendFlat": "estable", + "scatterSummary": "Gráfico de correlación, {count} puntos que comparan {xLabel} con {yLabel}.", + "scatterNoData": "Gráfico de correlación de {xLabel} frente a {yLabel}. No hay datos disponibles.", + "moodHeatmap": "Mapa de calor de estado de ánimo de {start} a {end}, {days} días registrados.", + "moodHeatmapEmpty": "Mapa de calor de estado de ánimo, aún no hay días registrados.", + "complianceHeatmap": "Mapa de calor de cumplimiento de medicación de {start} a {end}, {days} días activos.", + "complianceHeatmapEmpty": "Mapa de calor de cumplimiento de medicación, aún no hay días activos." + } }, "chart": { "overlay": { @@ -6173,7 +6188,9 @@ "resetToDefault": "Restablecer por defecto", "saveSuccess": "Rango objetivo actualizado", "saveError": "No se ha podido guardar el rango objetivo", - "resetSuccess": "Rango objetivo restablecido por defecto" + "resetSuccess": "Rango objetivo restablecido por defecto", + "validationOrder": "El mínimo debe ser inferior al máximo.", + "validationBounds": "Introduce valores dentro del rango permitido que se muestra debajo." }, "currentValue": "Actual: {value} {unit}", "targetRangeValue": "Rango objetivo: {min}–{max} {unit}", @@ -6831,7 +6848,8 @@ "empty": "No hay revisiones próximas.", "openLink": "Abrir revisiones" }, - "showMeasurements": "Ver mediciones" + "showMeasurements": "Ver mediciones", + "loadError": "No se pudieron cargar tus recordatorios." }, "moduleList": { "viewToggleLabel": "Vista", @@ -7312,7 +7330,8 @@ "BLOOD_PRESSURE_DIA": "Diastolic blood pressure", "PULSE": "Pulse", "WEIGHT": "Weight" - } + }, + "listLoadError": "No se pudieron cargar tus episodios." }, "coach": { "reminderSuggestion": { @@ -7372,5 +7391,12 @@ "glucoseHighDoctor": "Dos lecturas recientes fueron de {value} mg/dL o más. Vuelve a medir, hidrátate y contacta a tu médico cuanto antes si sigue así de alta.", "glucoseHighEmergency": "Dos lecturas recientes fueron de {value} mg/dL o más e indicaste síntomas. Si tienes vómitos, respiración rápida o confusión, llama a emergencias ya." } + }, + "version": { + "updateAvailable": { + "title": "Hay una nueva versión disponible", + "reload": "Recargar", + "dismiss": "Descartar" + } } } diff --git a/messages/fr.json b/messages/fr.json index 69f085b74..b3002dc3e 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -35,7 +35,8 @@ "showPassword": "Afficher le mot de passe", "hidePassword": "Masquer le mot de passe", "learnMore": "En savoir plus", - "dismiss": "Ignorer" + "dismiss": "Ignorer", + "moreInfo": "Plus d'informations" }, "doctorReport": { "title": "Rapport de santé", @@ -750,7 +751,8 @@ "sectionFactors": "Facteurs", "sectionNoteDetails": "Note et détails", "quickTagsHelp": "Vos tags fréquents — en un geste", - "quickTagsExpand": "Plus" + "quickTagsExpand": "Plus", + "loadError": "Impossible de charger vos entrées d'humeur." }, "medications": { "title": "Médicaments", @@ -1852,7 +1854,20 @@ "sat": "Samedi", "sun": "Dimanche" }, - "rangeBand": "Plage" + "rangeBand": "Plage", + "a11y": { + "summary": "Graphique {metric}. Plage de {min} à {max}, dernier {latest}, tendance {trend}.", + "noData": "Graphique {metric}. Aucune donnée disponible.", + "trendUp": "en hausse", + "trendDown": "en baisse", + "trendFlat": "stable", + "scatterSummary": "Graphique de corrélation, {count} points comparant {xLabel} et {yLabel}.", + "scatterNoData": "Graphique de corrélation de {xLabel} et {yLabel}. Aucune donnée disponible.", + "moodHeatmap": "Carte thermique de l'humeur du {start} au {end}, {days} jours enregistrés.", + "moodHeatmapEmpty": "Carte thermique de l'humeur, aucun jour enregistré pour l'instant.", + "complianceHeatmap": "Carte thermique d'observance des médicaments du {start} au {end}, {days} jours actifs.", + "complianceHeatmapEmpty": "Carte thermique d'observance des médicaments, aucun jour actif pour l'instant." + } }, "chart": { "overlay": { @@ -6173,7 +6188,9 @@ "resetToDefault": "Restaurer la valeur par défaut", "saveSuccess": "Plage cible mise à jour", "saveError": "Impossible d’enregistrer la plage cible", - "resetSuccess": "Plage cible réinitialisée" + "resetSuccess": "Plage cible réinitialisée", + "validationOrder": "Le minimum doit être inférieur au maximum.", + "validationBounds": "Saisissez des valeurs dans la plage autorisée indiquée ci-dessous." }, "currentValue": "Actuel : {value} {unit}", "targetRangeValue": "Plage cible : {min}–{max} {unit}", @@ -6831,7 +6848,8 @@ "empty": "Aucun rappel d’examen à venir.", "openLink": "Ouvrir les examens" }, - "showMeasurements": "Afficher les mesures" + "showMeasurements": "Afficher les mesures", + "loadError": "Impossible de charger vos rappels." }, "moduleList": { "viewToggleLabel": "Affichage", @@ -7312,7 +7330,8 @@ "BLOOD_PRESSURE_DIA": "Diastolic blood pressure", "PULSE": "Pulse", "WEIGHT": "Weight" - } + }, + "listLoadError": "Impossible de charger vos épisodes." }, "coach": { "reminderSuggestion": { @@ -7372,5 +7391,12 @@ "glucoseHighDoctor": "Deux mesures récentes étaient à {value} mg/dL ou plus. Remesure, hydrate-toi et contacte ton médecin dès que possible si cela reste aussi élevé.", "glucoseHighEmergency": "Deux mesures récentes étaient à {value} mg/dL ou plus et tu as signalé des symptômes. En cas de vomissements, de respiration rapide ou de confusion, appelle les secours immédiatement." } + }, + "version": { + "updateAvailable": { + "title": "Une nouvelle version est disponible", + "reload": "Recharger", + "dismiss": "Ignorer" + } } } diff --git a/messages/it.json b/messages/it.json index 60e5288d4..2a234387f 100644 --- a/messages/it.json +++ b/messages/it.json @@ -35,7 +35,8 @@ "showPassword": "Mostra password", "hidePassword": "Nascondi password", "learnMore": "Scopri di più", - "dismiss": "Ignora" + "dismiss": "Ignora", + "moreInfo": "Maggiori informazioni" }, "doctorReport": { "title": "Rapporto sanitario", @@ -750,7 +751,8 @@ "sectionFactors": "Fattori", "sectionNoteDetails": "Nota e dettagli", "quickTagsHelp": "I tuoi tag frequenti: un tocco", - "quickTagsExpand": "Altro" + "quickTagsExpand": "Altro", + "loadError": "Impossibile caricare le voci dell'umore." }, "medications": { "title": "Farmaci", @@ -1852,7 +1854,20 @@ "sat": "Sabato", "sun": "Domenica" }, - "rangeBand": "Intervallo" + "rangeBand": "Intervallo", + "a11y": { + "summary": "Grafico {metric}. Intervallo da {min} a {max}, ultimo {latest}, tendenza {trend}.", + "noData": "Grafico {metric}. Nessun dato disponibile.", + "trendUp": "in aumento", + "trendDown": "in calo", + "trendFlat": "stabile", + "scatterSummary": "Grafico di correlazione, {count} punti che confrontano {xLabel} con {yLabel}.", + "scatterNoData": "Grafico di correlazione di {xLabel} e {yLabel}. Nessun dato disponibile.", + "moodHeatmap": "Mappa di calore dell'umore dal {start} al {end}, {days} giorni registrati.", + "moodHeatmapEmpty": "Mappa di calore dell'umore, nessun giorno ancora registrato.", + "complianceHeatmap": "Mappa di calore dell'aderenza ai farmaci dal {start} al {end}, {days} giorni attivi.", + "complianceHeatmapEmpty": "Mappa di calore dell'aderenza ai farmaci, nessun giorno attivo." + } }, "chart": { "overlay": { @@ -6173,7 +6188,9 @@ "resetToDefault": "Ripristina predefinito", "saveSuccess": "Range obiettivo aggiornato", "saveError": "Impossibile salvare il range obiettivo", - "resetSuccess": "Range obiettivo ripristinato" + "resetSuccess": "Range obiettivo ripristinato", + "validationOrder": "Il minimo deve essere inferiore al massimo.", + "validationBounds": "Inserisci valori entro l'intervallo consentito indicato sotto." }, "currentValue": "Attuale: {value} {unit}", "targetRangeValue": "Range obiettivo: {min}–{max} {unit}", @@ -6831,7 +6848,8 @@ "empty": "Nessun promemoria di controllo in arrivo.", "openLink": "Apri controlli" }, - "showMeasurements": "Mostra le misurazioni" + "showMeasurements": "Mostra le misurazioni", + "loadError": "Impossibile caricare i promemoria." }, "moduleList": { "viewToggleLabel": "Vista", @@ -7312,7 +7330,8 @@ "BLOOD_PRESSURE_DIA": "Diastolic blood pressure", "PULSE": "Pulse", "WEIGHT": "Weight" - } + }, + "listLoadError": "Impossibile caricare gli episodi." }, "coach": { "reminderSuggestion": { @@ -7372,5 +7391,12 @@ "glucoseHighDoctor": "Due misurazioni recenti erano di {value} mg/dL o superiori. Rimisura, idratati e contatta il medico al più presto se resta così alta.", "glucoseHighEmergency": "Due misurazioni recenti erano di {value} mg/dL o superiori e hai segnalato sintomi. In caso di vomito, respiro affannoso o confusione, chiama subito i soccorsi." } + }, + "version": { + "updateAvailable": { + "title": "È disponibile una nuova versione", + "reload": "Ricarica", + "dismiss": "Ignora" + } } } diff --git a/messages/pl.json b/messages/pl.json index 5631b6f90..8d04bfe04 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -35,7 +35,8 @@ "showPassword": "Pokaż hasło", "hidePassword": "Ukryj hasło", "learnMore": "Dowiedz się więcej", - "dismiss": "Odrzuć" + "dismiss": "Odrzuć", + "moreInfo": "Więcej informacji" }, "doctorReport": { "title": "Raport zdrowotny", @@ -750,7 +751,8 @@ "sectionFactors": "Czynniki", "sectionNoteDetails": "Notatka i szczegóły", "quickTagsHelp": "Twoje częste tagi — jedno dotknięcie", - "quickTagsExpand": "Więcej" + "quickTagsExpand": "Więcej", + "loadError": "Nie udało się załadować wpisów nastroju." }, "medications": { "title": "Leki", @@ -1852,7 +1854,20 @@ "sat": "Sobota", "sun": "Niedziela" }, - "rangeBand": "Zakres" + "rangeBand": "Zakres", + "a11y": { + "summary": "Wykres {metric}. Zakres od {min} do {max}, ostatnio {latest}, trend {trend}.", + "noData": "Wykres {metric}. Brak dostępnych danych.", + "trendUp": "rosnący", + "trendDown": "malejący", + "trendFlat": "stały", + "scatterSummary": "Wykres korelacji, {count} punktów porównujących {xLabel} z {yLabel}.", + "scatterNoData": "Wykres korelacji {xLabel} i {yLabel}. Brak dostępnych danych.", + "moodHeatmap": "Mapa cieplna nastroju od {start} do {end}, zarejestrowano {days} dni.", + "moodHeatmapEmpty": "Mapa cieplna nastroju, brak zarejestrowanych dni.", + "complianceHeatmap": "Mapa cieplna przyjmowania leków od {start} do {end}, {days} aktywnych dni.", + "complianceHeatmapEmpty": "Mapa cieplna przyjmowania leków, brak aktywnych dni." + } }, "chart": { "overlay": { @@ -6173,7 +6188,9 @@ "resetToDefault": "Przywróć wartość domyślną", "saveSuccess": "Zakres docelowy zaktualizowany", "saveError": "Nie udało się zapisać zakresu docelowego", - "resetSuccess": "Zakres docelowy przywrócono do domyślnego" + "resetSuccess": "Zakres docelowy przywrócono do domyślnego", + "validationOrder": "Wartość minimalna musi być niższa niż maksymalna.", + "validationBounds": "Wprowadź wartości w dozwolonym zakresie podanym poniżej." }, "currentValue": "Aktualnie: {value} {unit}", "targetRangeValue": "Zakres docelowy: {min}–{max} {unit}", @@ -6831,7 +6848,8 @@ "empty": "Brak nadchodzących przypomnień o badaniach.", "openLink": "Otwórz badania" }, - "showMeasurements": "Pokaż pomiary" + "showMeasurements": "Pokaż pomiary", + "loadError": "Nie udało się załadować przypomnień." }, "moduleList": { "viewToggleLabel": "Widok", @@ -7312,7 +7330,8 @@ "BLOOD_PRESSURE_DIA": "Diastolic blood pressure", "PULSE": "Pulse", "WEIGHT": "Weight" - } + }, + "listLoadError": "Nie udało się załadować epizodów." }, "coach": { "reminderSuggestion": { @@ -7372,5 +7391,12 @@ "glucoseHighDoctor": "Dwa ostatnie pomiary wyniosły {value} mg/dL lub więcej. Zmierz ponownie, nawodnij się i jak najszybciej skontaktuj się z lekarzem, jeśli pozostaje tak wysoki.", "glucoseHighEmergency": "Dwa ostatnie pomiary wyniosły {value} mg/dL lub więcej i zgłosiłeś objawy. W razie wymiotów, przyspieszonego oddechu lub dezorientacji natychmiast zadzwoń po pomoc." } + }, + "version": { + "updateAvailable": { + "title": "Dostępna jest nowa wersja", + "reload": "Załaduj ponownie", + "dismiss": "Odrzuć" + } } } From 51d67af28f837e74fb0cf8f82c065eb91ede695c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 02:52:53 +0200 Subject: [PATCH 2/5] fix(charts): give charts and heatmaps a screen-reader text alternative Wrap the shared HealthChart plus the mood, medication-compliance, and scatter-correlation charts in role="img" with a concise aria-label that states the metric, value range, latest reading, and trend direction, so VoiceOver and TalkBack announce something for every chart cell and the ~50 insight pages behind the shared component. Add the same role="img" summary to the mood and compliance heatmaps, route their tooltip dates through the locale- and timezone-aware formatter instead of a hardcoded German format, and clamp the pinned tooltip to the viewport so it no longer overflows the right edge on a narrow phone. --- .../health-chart-a11y-label.test.tsx | 69 +++++++++++++++++++ src/components/charts/compliance-heatmap.tsx | 41 +++++++---- src/components/charts/health-chart.tsx | 51 +++++++++++++- .../charts/medication-compliance-chart.tsx | 47 ++++++++++++- src/components/charts/mood-chart.tsx | 35 ++++++++++ src/components/charts/mood-heatmap.tsx | 38 +++++++--- .../charts/scatter-correlation-chart.tsx | 19 +++++ 7 files changed, 276 insertions(+), 24 deletions(-) create mode 100644 src/components/charts/__tests__/health-chart-a11y-label.test.tsx diff --git a/src/components/charts/__tests__/health-chart-a11y-label.test.tsx b/src/components/charts/__tests__/health-chart-a11y-label.test.tsx new file mode 100644 index 000000000..8a02ddda0 --- /dev/null +++ b/src/components/charts/__tests__/health-chart-a11y-label.test.tsx @@ -0,0 +1,69 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import deMessages from "../../../../messages/de.json"; + +/** + * Chart accessibility — the rendered chart must expose an image role with + * a non-empty data-summary label so a screen reader announces the series + * instead of skipping a silent SVG. Mirrors the empty-window render test: + * mock auth + react-query, render to static markup, assert the DOM. + */ + +function buildData(): unknown[] { + const out: Array<{ date: string; timestamp: number; PULSE: number }> = []; + const base = Date.UTC(2026, 0, 1, 12, 0, 0); + for (let i = 0; i < 10; i++) { + out.push({ + date: `2026-01-${String(i + 1).padStart(2, "0")}`, + timestamp: base + i * 86_400_000, + PULSE: 60 + i, + }); + } + return out; +} + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + isAuthenticated: true, + user: null, + isLoading: false, + }), +})); + +describe(" — accessibility label", () => { + beforeEach(() => { + vi.resetModules(); + }); + + it("wraps the chart in role=img with a non-empty aria-label when data is present", async () => { + const data = buildData(); + + vi.doMock("@tanstack/react-query", () => ({ + useQuery: () => ({ data, isLoading: false }), + useQueryClient: () => ({ + cancelQueries: () => Promise.resolve(), + getQueryData: () => undefined, + setQueryData: () => undefined, + invalidateQueries: () => Promise.resolve(), + }), + useMutation: () => ({ mutate: () => undefined, isPending: false }), + })); + + const { I18nProvider } = await import("@/lib/i18n/context"); + const { HealthChart } = await import("../health-chart"); + + const html = renderToStaticMarkup( + + + , + ); + + expect(html).toContain('role="img"'); + // A role=img wrapper with an empty/absent label is worse than none — + // assert the aria-label attribute carries actual text. + const match = html.match(/role="img"[^>]*aria-label="([^"]*)"/); + expect(match?.[1] ?? "").not.toBe(""); + + vi.doUnmock("@tanstack/react-query"); + }); +}); diff --git a/src/components/charts/compliance-heatmap.tsx b/src/components/charts/compliance-heatmap.tsx index e92a03f48..06071fb69 100644 --- a/src/components/charts/compliance-heatmap.tsx +++ b/src/components/charts/compliance-heatmap.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useMemo, useEffect, useRef } from "react"; -import { useTranslations } from "@/lib/i18n/context"; +import { useTranslations, useFormatters } from "@/lib/i18n/context"; interface DailyData { expected: number; @@ -72,22 +72,17 @@ function getColor(data: DailyData): string { return "var(--dracula-red)"; } -function formatDateDE(dateStr: string): string { - // v1.4.27 B7 / BL-P4-2 — parse the day-key against UTC so the - // formatted tooltip label matches the dateKey computation below - // (which uses `toISOString().slice(0, 10)`). Without the UTC - // anchor an SSR server in a non-Berlin timezone could format the - // tick a day off the dateKey it sits under. - const d = new Date(dateStr + "T00:00:00Z"); - return `${d.getUTCDate()}.${d.getUTCMonth() + 1}.${d.getUTCFullYear()}`; -} - export function ComplianceHeatmap({ dailyCompliance, days = 90, stretch = false, }: ComplianceHeatmapProps) { const { t } = useTranslations(); + const fmt = useFormatters(); + // Day keys are UTC-anchored "YYYY-MM-DD"; format at noon UTC so the + // locale-aware renderer never lands a day off in either direction. + const formatDay = (dateKey: string) => + fmt.date(new Date(`${dateKey}T12:00:00Z`)); const containerRef = useRef(null); const [containerWidth, setContainerWidth] = useState(0); const [tooltip, setTooltip] = useState<{ @@ -230,6 +225,16 @@ export function ComplianceHeatmap({ return { cells: cellList, weeks: col + 1, monthMarkers: markers }; }, [dailyCompliance, days, t]); + const activeDays = cells.filter((cell) => cell.data.expected > 0).length; + const summaryLabel = + cells.length === 0 || activeDays === 0 + ? t("charts.a11y.complianceHeatmapEmpty") + : t("charts.a11y.complianceHeatmap", { + start: formatDay(cells[0].dateKey), + end: formatDay(cells[cells.length - 1].dateKey), + days: activeDays, + }); + const labelWidth = stretch ? 0 : 76; const headerHeight = 18; // v1.4.27 MB7 / CF-10 — clamp the stretch-branch adaptive cell to the @@ -268,6 +273,8 @@ export function ComplianceHeatmap({ {tooltip.text} diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index 6ea6cff27..60f97d96a 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -1386,6 +1386,51 @@ export function HealthChart({ const formatTooltipValue = (value: number) => fmt.number(value, 1); + // Accessibility — a concise spoken summary of the primary series so a screen + // reader announces the shape of the chart (range, latest value, direction) + // instead of an unlabelled graphic. Mirrors the visible-range stats the + // chart already holds; falls back to the raw min/max when the stat strip + // is suppressed (mini mode). + const primaryType = types[0]; + const chartAriaLabel = useMemo(() => { + const metric = getTypeLabel(primaryType, valueMode, t); + const source = chartDataWithCompare ?? chartData; + const values = (source ?? []) + .map((point) => point[primaryType]) + .filter((v): v is number => typeof v === "number" && Number.isFinite(v)); + if (values.length === 0) { + return t("charts.a11y.noData", { metric }); + } + const stats = visibleStatsByType?.[primaryType]; + const minVal = stats?.min ?? Math.min(...values); + const maxVal = stats?.max ?? Math.max(...values); + const latestVal = values[values.length - 1]; + const firstVal = values[0]; + const epsilon = Math.max(Math.abs(maxVal - minVal) * 0.02, 0.01); + const delta = latestVal - firstVal; + const trend = + delta > epsilon + ? t("charts.a11y.trendUp") + : delta < -epsilon + ? t("charts.a11y.trendDown") + : t("charts.a11y.trendFlat"); + return t("charts.a11y.summary", { + metric, + min: fmt.number(minVal, 1), + max: fmt.number(maxVal, 1), + latest: fmt.number(latestVal, 1), + trend, + }); + }, [ + primaryType, + valueMode, + t, + chartDataWithCompare, + chartData, + visibleStatsByType, + fmt, + ]); + const trendInfo = useMemo(() => { if (!showTrend || !chartData?.length) return []; @@ -1734,7 +1779,11 @@ export function HealthChart({ ) : null} -
+
`${fmt.integer(value)} %`; const animationsEnabled = !prefersReducedMotion(); + // Accessibility — a concise spoken summary of the compliance series so a + // screen reader announces the adherence range, the latest day and the + // direction rather than an unlabelled graphic. Reuses the same 7-day trend + // the chip surfaces; falls back to a first-vs-last comparison when the + // window is too short for the trend helper. + const complianceAriaLabel = ((): string => { + const metric = displayTitle; + const rates = chartData + .map((p) => p.rate) + .filter((v): v is number => typeof v === "number" && Number.isFinite(v)); + if (rates.length === 0) { + return t("charts.a11y.noData", { metric }); + } + const pct = (v: number) => `${fmt.integer(v)} %`; + const minVal = Math.min(...rates); + const maxVal = Math.max(...rates); + const latestVal = rates[rates.length - 1]; + let trendWord: string; + if (trend && trend.direction !== "stable") { + trendWord = + trend.direction === "up" + ? t("charts.a11y.trendUp") + : t("charts.a11y.trendDown"); + } else { + const delta = latestVal - rates[0]; + trendWord = + delta > 1 + ? t("charts.a11y.trendUp") + : delta < -1 + ? t("charts.a11y.trendDown") + : t("charts.a11y.trendFlat"); + } + return t("charts.a11y.summary", { + metric, + min: pct(minVal), + max: pct(maxVal), + latest: pct(latestVal), + trend: trendWord, + }); + })(); + // Empty-state guard: if the user has zero scheduled doses across the // whole window we render the title + a "no data" hint, mirroring how // the other charts handle the empty case (HealthChart returns null @@ -475,7 +516,11 @@ export function MedicationComplianceChart({ /> ) ) : ( -
+
{ + const metric = displayTitle; + const scores = (chartData ?? []) + .map((d) => d.score) + .filter((v): v is number => typeof v === "number" && Number.isFinite(v)); + if (scores.length === 0) { + return t("charts.a11y.noData", { metric }); + } + const fmtScore = (v: number) => String(Math.round(v * 10) / 10); + const minVal = Math.min(...scores); + const maxVal = Math.max(...scores); + const latestVal = scores[scores.length - 1]; + const firstVal = scores[0]; + const epsilon = 0.1; + const delta = latestVal - firstVal; + const trend = + delta > epsilon + ? t("charts.a11y.trendUp") + : delta < -epsilon + ? t("charts.a11y.trendDown") + : t("charts.a11y.trendFlat"); + return t("charts.a11y.summary", { + metric, + min: fmtScore(minVal), + max: fmtScore(maxVal), + latest: fmtScore(latestVal), + trend, + }); + })(); + const chartBody = isLoading ? ( // v1.16.0 — height-matched skeleton band instead of the former // `h-48` spinner box: the loading state occupies the same box the @@ -680,6 +713,8 @@ export function MoodChart({ ? "h-[var(--chart-height,140px)]" : "h-[var(--chart-height,200px)] md:h-[var(--chart-height-md,220px)]" } touch-pan-y`} + role="img" + aria-label={moodAriaLabel} > + fmt.date(new Date(`${dateKey}T12:00:00Z`)); const containerRef = useRef(null); const [containerWidth, setContainerWidth] = useState(0); const [tooltip, setTooltip] = useState<{ @@ -185,6 +185,16 @@ export function MoodHeatmap({ return { cells: cellList, weeks: col + 1, monthMarkers: markers }; }, [cellData, days, t]); + const loggedDays = cells.filter((cell) => cell.cell !== null).length; + const summaryLabel = + cells.length === 0 || loggedDays === 0 + ? t("charts.a11y.moodHeatmapEmpty") + : t("charts.a11y.moodHeatmap", { + start: formatDay(cells[0].dateKey), + end: formatDay(cells[cells.length - 1].dateKey), + days: loggedDays, + }); + const labelWidth = stretch ? 0 : 76; const headerHeight = 18; const cellSize = @@ -214,6 +224,8 @@ export function MoodHeatmap({ { const buildText = (): string => { if (!cell.cell) { - return `${formatDateDE(cell.dateKey)}: ${t("insights.mood.heatmapNoEntry")}`; + return `${formatDay(cell.dateKey)}: ${t("insights.mood.heatmapNoEntry")}`; } const labelKey = moodLabelKeyForScore( Math.round(cell.cell.score), ); const moodLabel = labelKey ? t(labelKey) : ""; - return `${formatDateDE(cell.dateKey)}: ${cell.cell.score.toFixed(1)}${moodLabel ? ` · ${moodLabel}` : ""}`; + return `${formatDay(cell.dateKey)}: ${cell.cell.score.toFixed(1)}${moodLabel ? ` · ${moodLabel}` : ""}`; }; return ( {tooltip.text}
diff --git a/src/components/charts/scatter-correlation-chart.tsx b/src/components/charts/scatter-correlation-chart.tsx index 1f6f9f3ed..a75d3d7ac 100644 --- a/src/components/charts/scatter-correlation-chart.tsx +++ b/src/components/charts/scatter-correlation-chart.tsx @@ -10,6 +10,7 @@ import { XAxis, YAxis, } from "recharts"; +import { useTranslations } from "@/lib/i18n/context"; /** * Self-contained correlation scatter chart used by /insights. @@ -82,6 +83,22 @@ export function ScatterCorrelationChart>({ tooltipFormatter, height = 250, }: ScatterCorrelationChartProps) { + const { t } = useTranslations(); + // Accessibility — a concise spoken summary so a screen reader announces + // what the scatter relates and how many points it plots rather than an + // unlabelled graphic. + const count = data?.length ?? 0; + const summary = + count === 0 + ? t("charts.a11y.scatterNoData", { + xLabel: xAxis.name, + yLabel: yAxis.name, + }) + : t("charts.a11y.scatterSummary", { + count, + xLabel: xAxis.name, + yLabel: yAxis.name, + }); // v1.4.27 MB7 / CF-37 — drop the fixed pixel height in favour of a // responsive aspect-ratio on mobile so the chart never compresses // below readable axes. The `aspect-square` ratio at `>({ className="aspect-square min-h-[180px] touch-pan-y sm:aspect-[3/2] sm:h-auto" style={{ height: undefined }} data-explicit-height={height} + role="img" + aria-label={summary} > From a74a0c9b0925aa7f2ff81797cabd51c11e039e14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 02:52:59 +0200 Subject: [PATCH 3/5] fix(version-poller): surface a reload toast instead of a silent hard reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 60s poller used to unregister service workers, wipe CacheStorage, and call location.reload() the moment it saw a version bump, with no prompt — firing mid-form, mid-message, or mid-wizard and discarding whatever the user had typed. Keep detecting the new build, but now show a non-blocking, dismissible toast with a Reload action; the eviction and reload run only when the user chooses to reload. Show the toast once per detected version. --- .../__tests__/version-poller.test.ts | 16 +++--- src/components/version-poller.tsx | 54 ++++++++++++++----- 2 files changed, 50 insertions(+), 20 deletions(-) diff --git a/src/components/__tests__/version-poller.test.ts b/src/components/__tests__/version-poller.test.ts index b079fea7a..fb3a6b938 100644 --- a/src/components/__tests__/version-poller.test.ts +++ b/src/components/__tests__/version-poller.test.ts @@ -1,12 +1,16 @@ /** - * Unit tests for the version-poller reload decision. + * Unit tests for the version-poller decision. * * The guard is keyed on the TARGET (live) version: a repeat mismatch - * against the SAME version is suppressed (no reload loop on a misset - * server), but a SECOND deploy in one session moves the live version - * past the recorded one and the heal flow re-arms. The pre-v1.16.8 - * guard disabled all polling after one attempt, which stranded stale - * shells on multi-deploy days. + * against the SAME version is suppressed (no loop on a misset server), + * but a SECOND deploy in one session moves the live version past the + * recorded one and the flow re-arms. + * + * The runtime behaviour — a detected bump surfaces a Reload toast and + * never reloads the page on its own — lives in `version-poller.tsx`: + * the service-worker eviction + `location.reload()` run only from the + * toast action's `onClick`, so an in-progress form or chat draft is + * never destroyed unprompted. */ import { describe, expect, it } from "vitest"; diff --git a/src/components/version-poller.tsx b/src/components/version-poller.tsx index a4514311e..b5a7420dc 100644 --- a/src/components/version-poller.tsx +++ b/src/components/version-poller.tsx @@ -1,8 +1,10 @@ "use client"; -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; +import { toast } from "sonner"; import { apiGet } from "@/lib/api/api-fetch"; +import { useTranslations } from "@/lib/i18n/context"; /** * v1.4.38.4 — runtime self-heal for the stale-shell post-deploy @@ -20,7 +22,10 @@ import { apiGet } from "@/lib/api/api-fetch"; * `/api/version` and compares the live version string against * `NEXT_PUBLIC_APP_VERSION` (injected from `package.json` at build * time by `next.config.ts`). When the live version moves ahead of - * the running shell: + * the running shell it surfaces a non-blocking toast with a Reload + * action. The eviction + reload only runs when the user clicks + * Reload — an in-progress form or chat draft is never destroyed by a + * silent mid-session reload. On that click: * * 1. Unregister every active service worker so the next page load * doesn't reinstall the old SW. @@ -30,15 +35,15 @@ import { apiGet } from "@/lib/api/api-fetch"; * 3. `window.location.reload()` — fetches the fresh shell + new * chunk graph. * - * The reload guard is keyed on the TARGET version: `sessionStorage` - * records which live version we last reloaded for, and only a repeat - * mismatch against that SAME version is suppressed (a misset version — - * server briefly serves a stale image mid-deploy — cannot loop). - * Polling itself never stops, so on a multi-deploy day a SECOND - * release moves the live version past the recorded one and the heal - * flow re-arms. The pre-v1.16.8 guard disabled all polling for the - * rest of the session after one attempt, which left a stale shell - * with 404ing chunks stuck forever once the guard was spent. + * The toast is shown once per detected version (the poll keeps + * running, but a repeated mismatch against a version we already + * surfaced doesn't re-spam every 60 s). The reload guard is keyed on + * the TARGET version: `sessionStorage` records which live version we + * last reloaded for, and only a repeat mismatch against that SAME + * version is suppressed (a misset version — server briefly serves a + * stale image mid-deploy — cannot loop). On a multi-deploy day a + * SECOND release moves the live version past the recorded one and the + * flow re-arms. */ const POLL_INTERVAL_MS = 60_000; @@ -116,6 +121,11 @@ async function evictAndReload(targetVersion: string): Promise { } export function VersionPoller(): null { + const { t } = useTranslations(); + // Tracks the live version we've already surfaced a toast for, so a + // repeated mismatch on the recurring poll doesn't re-spam. + const notifiedForRef = useRef(null); + useEffect(() => { if (!SHELL_VERSION) return; if (typeof window === "undefined") return; @@ -137,11 +147,27 @@ export function VersionPoller(): null { lastReloadedFor, ); if (decision !== "reload") return; - await evictAndReload(live as string); + + const target = live as string; + // One toast per detected version — don't re-open it every poll. + if (notifiedForRef.current === target) return; + notifiedForRef.current = target; + + toast(t("version.updateAvailable.title"), { + duration: Infinity, + action: { + label: t("version.updateAvailable.reload"), + onClick: () => void evictAndReload(target), + }, + cancel: { + label: t("version.updateAvailable.dismiss"), + onClick: () => {}, + }, + }); } // First check 5 s after mount — gives the app a chance to settle - // before we touch caches. Then on the recurring interval. + // before we surface anything. Then on the recurring interval. const initial = window.setTimeout(() => void checkOnce(), 5_000); const interval = window.setInterval( () => void checkOnce(), @@ -153,7 +179,7 @@ export function VersionPoller(): null { window.clearInterval(interval); controller.abort(); }; - }, []); + }, [t]); return null; } From b02e3e4072579270fbcb08387be390eef9173c4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 02:53:06 +0200 Subject: [PATCH 4/5] fix(mobile): close safe-area gaps and lift sub-44px tap targets Subtract both the top and bottom safe-area insets from the full-page Coach height so the composer no longer slides behind the bottom-nav on a notched phone, and pad the Coach drawer composer and the ResponsiveSheet bottom-sheet footer for the home-indicator band. Raise the cycle and illness day-log severity controls to a 44px touch floor, add hit-slop to the custom-symptom remove button, give the shared day-log chips a min height, and let the mood-tag action cluster wrap so a 360px row no longer clips the tag name. --- src/app/coach/page.tsx | 2 +- src/components/cycle/log-day-sheet.tsx | 6 +- src/components/illness/log-day-sheet.tsx | 6 +- .../coach-panel/coach-drawer-body.tsx | 2 +- .../mood/manage/tag-manager-card.tsx | 206 +++++++++--------- src/components/ui/responsive-sheet.tsx | 2 +- 6 files changed, 114 insertions(+), 110 deletions(-) diff --git a/src/app/coach/page.tsx b/src/app/coach/page.tsx index 85870cf99..c956134fc 100644 --- a/src/app/coach/page.tsx +++ b/src/app/coach/page.tsx @@ -136,7 +136,7 @@ export default function CoachPage() { // `env(safe-area-inset-bottom)`) so the docked composer / Stop control // always clears the nav instead of sitting under it. On `md+` the nav // is hidden, so the page reclaims the full height below the TopBar. - className="bg-background -mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-8rem-env(safe-area-inset-bottom,0px))] min-h-[32rem] flex-col overflow-hidden md:-mx-6 md:h-[calc(100dvh-4rem)]" + className="bg-background -mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-8rem-env(safe-area-inset-top,0px)-env(safe-area-inset-bottom,0px))] min-h-[32rem] flex-col overflow-hidden md:-mx-6 md:h-[calc(100dvh-4rem)]" > {/* `useSearchParams` requires a Suspense boundary so the client-search bailout never opts the whole route out of static optimisation. */} diff --git a/src/components/cycle/log-day-sheet.tsx b/src/components/cycle/log-day-sheet.tsx index 00a78ba19..9a0c4797e 100644 --- a/src/components/cycle/log-day-sheet.tsx +++ b/src/components/cycle/log-day-sheet.tsx @@ -423,7 +423,7 @@ function Chip({ aria-pressed={active} onClick={onClick} className={cn( - "focus-visible:ring-ring/50 rounded-full border px-3 py-1.5 text-sm transition-colors focus-visible:ring-2 focus-visible:outline-none", + "focus-visible:ring-ring/50 inline-flex min-h-11 items-center rounded-full border px-3 py-1.5 text-sm transition-colors focus-visible:ring-2 focus-visible:outline-none sm:min-h-8", active ? "border-primary bg-primary/10 text-primary" : "border-border text-foreground hover:bg-accent", @@ -1225,7 +1225,7 @@ function SymptomChip({ onClick={handleRemove} disabled={deleteCustom.isPending} aria-label={t("cycle.symptom.custom.remove", { label })} - className="border-border bg-background text-muted-foreground hover:text-destructive hover:border-destructive focus-visible:ring-ring/50 -ml-1.5 grid size-4 shrink-0 place-items-center rounded-full border transition-colors focus-visible:ring-2 focus-visible:outline-none" + className="border-border bg-background text-muted-foreground hover:text-destructive hover:border-destructive focus-visible:ring-ring/50 relative -ml-1.5 grid size-4 shrink-0 place-items-center rounded-full border transition-colors before:absolute before:-inset-2 before:content-[''] focus-visible:ring-2 focus-visible:outline-none" >
); } diff --git a/src/components/ui/responsive-sheet.tsx b/src/components/ui/responsive-sheet.tsx index e212b3358..d302c99af 100644 --- a/src/components/ui/responsive-sheet.tsx +++ b/src/components/ui/responsive-sheet.tsx @@ -180,7 +180,7 @@ export function ResponsiveSheet({ {footer ? ( {footer} From c38271bc44ca05dcce368fd8d5e48b2a90be4e5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 02:53:14 +0200 Subject: [PATCH 5/5] fix(a11y): distinguish errors from empty states and close naming gaps Add isError branches with a retry affordance to the mood, illness, and vorsorge lists so a failed read no longer renders as "no entries yet" and invites duplicate re-entry, matching the labs and cycle pattern. Make the admin backups table reachable on a phone with a card fallback, name the admin language and timezone selects and the AI raw-data switch for screen readers, and replace the touch-unreachable title= help on the glucose clinical panels with a tap-capable info popover. Route the password-strength label through the AA-tuned semantic tokens, underline the register login link so it is not distinguished by colour alone, give the labs load error alert semantics, and explain why the target editor Save button is disabled with aria-invalid and a live alert. --- src/app/auth/register/page.tsx | 8 +- src/components/admin/backups-section.tsx | 194 ++++++++++++------ .../admin/general-settings-section.tsx | 2 + src/components/illness/illness-view.tsx | 33 ++- .../glucose/glucose-advanced-disclosure.tsx | 4 +- .../glucose/glucose-clinical-panel.tsx | 13 +- src/components/labs/lab-list.tsx | 2 +- .../vorsorge-section.tsx | 30 ++- src/components/mood/mood-list.tsx | 21 +- .../settings/ai/runtime-actions-row.tsx | 1 + src/components/targets/target-edit-sheet.tsx | 60 ++++++ src/components/ui/info-hint.tsx | 56 +++++ src/components/ui/password-strength.tsx | 14 +- 13 files changed, 350 insertions(+), 88 deletions(-) create mode 100644 src/components/ui/info-hint.tsx diff --git a/src/app/auth/register/page.tsx b/src/app/auth/register/page.tsx index 4c6173af1..73c1f10a6 100644 --- a/src/app/auth/register/page.tsx +++ b/src/app/auth/register/page.tsx @@ -194,7 +194,13 @@ export default function RegisterPage() {

{t("auth.hasAccount")}{" "} - + {t("auth.login")}

diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index 1ec8bbb11..f63244557 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -467,74 +467,134 @@ export function BackupsSection() { />
) : ( -
- - - - - - - - - - - - {rows.map((row, i) => ( - - - - - - +
+ {/* Wide layout: the canonical table. Hidden below `md`, where the + five columns push Download / Restore off-screen behind a + horizontal scroll; the card list below takes over there. */} +
+
- {t("admin.section.backups.colUser")} - - {t("admin.section.backups.colType")} - - {t("admin.section.backups.colSize")} - - {t("admin.section.backups.colCreatedAt")} - - {t("admin.section.backups.colActions")} -
{row.username} - - {formatBackupType(row.type, t)} - - - {formatBytes(row.sizeBytes, fmt)} - - {fmt.dateTime(row.createdAt)} - -
- - restore.mutate(row)} - /> -
-
+ + + + + + + - ))} - -
+ {t("admin.section.backups.colUser")} + + {t("admin.section.backups.colType")} + + {t("admin.section.backups.colSize")} + + {t("admin.section.backups.colCreatedAt")} + + {t("admin.section.backups.colActions")} +
+ + + {rows.map((row, i) => ( + + {row.username} + + + {formatBackupType(row.type, t)} + + + + {formatBytes(row.sizeBytes, fmt)} + + + {fmt.dateTime(row.createdAt)} + + +
+ + restore.mutate(row)} + /> +
+ + + ))} + + +
+ + {/* Narrow layout: each backup as a self-contained card with the + meta line + a flex-wrapping action row, so Download / Restore + stay reachable on a phone instead of scrolling off-screen. */} +
    + {rows.map((row) => ( +
  • +
    + {row.username} + + {formatBackupType(row.type, t)} + +
    +

    + {t("admin.section.backups.colSize")}:{" "} + {formatBytes(row.sizeBytes, fmt)} ·{" "} + {fmt.dateTime(row.createdAt)} +

    +
    + + restore.mutate(row)} + /> +
    +
  • + ))} +
+

{t("admin.section.backups.retentionLabel", { days: data!.retentionDays, diff --git a/src/components/admin/general-settings-section.tsx b/src/components/admin/general-settings-section.tsx index 8e1dc1011..62cb0d438 100644 --- a/src/components/admin/general-settings-section.tsx +++ b/src/components/admin/general-settings-section.tsx @@ -68,6 +68,7 @@ export function GeneralSettingsSection() {

updateSettings.mutate({ defaultLocale: e.target.value }) @@ -94,6 +95,7 @@ export function GeneralSettingsSection() {

updateSettings.mutate({ defaultUserTimezone: e.target.value }) diff --git a/src/components/illness/illness-view.tsx b/src/components/illness/illness-view.tsx index 4db8ab460..a6559cc06 100644 --- a/src/components/illness/illness-view.tsx +++ b/src/components/illness/illness-view.tsx @@ -17,7 +17,13 @@ */ import { useId, useMemo, useState } from "react"; import Link from "next/link"; -import { ChevronDown, Plus, Stethoscope, Wrench } from "lucide-react"; +import { + ChevronDown, + Plus, + RefreshCw, + Stethoscope, + Wrench, +} from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -308,7 +314,12 @@ function EpisodeGroup({ export function IllnessView() { const { t } = useTranslations(); - const { data: episodes, isLoading } = useIllnessEpisodes(true); + const { + data: episodes, + isLoading, + isError, + refetch, + } = useIllnessEpisodes(true); const resolve = useResolveEpisode(); const { prefs } = useModuleListPrefs("illness"); @@ -418,6 +429,24 @@ export function IllnessView() { + ) : isError ? ( +
+ {t("illness.listLoadError")} + +
) : hasEpisodes ? ( <> {/* Retrospective summary sits above the list so a long history diff --git a/src/components/insights/glucose/glucose-advanced-disclosure.tsx b/src/components/insights/glucose/glucose-advanced-disclosure.tsx index 362dfcc29..4392173f2 100644 --- a/src/components/insights/glucose/glucose-advanced-disclosure.tsx +++ b/src/components/insights/glucose/glucose-advanced-disclosure.tsx @@ -3,6 +3,7 @@ import { useId, useState } from "react"; import { ChevronDown } from "lucide-react"; +import { InfoHint } from "@/components/ui/info-hint"; import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; import type { GlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics"; @@ -146,8 +147,9 @@ function AdvancedStat({ }) { return (
-
+
{label} +
{value} diff --git a/src/components/insights/glucose/glucose-clinical-panel.tsx b/src/components/insights/glucose/glucose-clinical-panel.tsx index cbc1535fc..b27317673 100644 --- a/src/components/insights/glucose/glucose-clinical-panel.tsx +++ b/src/components/insights/glucose/glucose-clinical-panel.tsx @@ -5,6 +5,7 @@ import { Activity, Droplet, FlaskConical, TrendingUp } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { InfoHint } from "@/components/ui/info-hint"; import { LearningGate } from "@/components/ui/learning-gate"; import { LearnMoreLink } from "@/components/ui/learn-more-link"; import { Skeleton } from "@/components/ui/skeleton"; @@ -195,11 +196,9 @@ export function GlucoseClinicalPanel() { ) : null} {cv ? (
- + {t("insights.bloodGlucose.clinical.cv.label")} +
@@ -283,12 +282,10 @@ function Metric({ }) { return (
- + {icon} {label} + {help ? : null} {value} diff --git a/src/components/labs/lab-list.tsx b/src/components/labs/lab-list.tsx index 1f45b1518..e0c597f9d 100644 --- a/src/components/labs/lab-list.tsx +++ b/src/components/labs/lab-list.tsx @@ -165,7 +165,7 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) { if (isError) { return ( -

+

{t("labs.loadError")}

); diff --git a/src/components/measurement-reminders/vorsorge-section.tsx b/src/components/measurement-reminders/vorsorge-section.tsx index 280702666..e43368c38 100644 --- a/src/components/measurement-reminders/vorsorge-section.tsx +++ b/src/components/measurement-reminders/vorsorge-section.tsx @@ -35,6 +35,7 @@ import { CalendarClock, CheckCircle2, Plus, + RefreshCw, Wrench, } from "lucide-react"; @@ -219,7 +220,12 @@ export function VorsorgeSection({ variant?: "settings" | "page"; }) { const { t } = useTranslations(); - const { data: reminders, isLoading } = useMeasurementReminders(enabled); + const { + data: reminders, + isLoading, + isError, + refetch, + } = useMeasurementReminders(enabled); const { create, update, remove, satisfy } = useMeasurementReminderMutations(); // v1.18.6 (MOD-03) — page view (cards/list) + manual order persist // client-side; the settings page writes them, the page reads them. @@ -662,7 +668,27 @@ export function VorsorgeSection({
)} - {!isLoading && (reminders?.length ?? 0) === 0 && ( + {!isLoading && isError && ( +
+ {t("measurementReminders.loadError")} + +
+ )} + + {!isLoading && !isError && (reminders?.length ?? 0) === 0 && ( } title={t("measurementReminders.empty.title")} diff --git a/src/components/mood/mood-list.tsx b/src/components/mood/mood-list.tsx index 772f902f6..d5a90d6f4 100644 --- a/src/components/mood/mood-list.tsx +++ b/src/components/mood/mood-list.tsx @@ -43,6 +43,7 @@ import { Loader2, Pencil, Plus, + RefreshCw, Smile, Trash2, ChevronLeft, @@ -219,7 +220,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { const toParam = toDay || undefined; const sourceParam = sourceFilter === "ALL" ? undefined : sourceFilter; - const { data, isLoading } = useQuery({ + const { data, isLoading, isError, refetch } = useQuery({ queryKey: queryKeys.moodEntriesList({ mood: moodFilter === "ALL" ? undefined : moodFilter, source: sourceParam, @@ -492,6 +493,24 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
+ ) : isError ? ( +
+ {t("mood.loadError")} + +
) : !data?.entries?.length ? ( // v1.4.15 phase-C5: replace bare-text empty rectangle with // EmptyState. Filter-aware copy splits "no mood entries yet" diff --git a/src/components/settings/ai/runtime-actions-row.tsx b/src/components/settings/ai/runtime-actions-row.tsx index c83a8f670..070ef3730 100644 --- a/src/components/settings/ai/runtime-actions-row.tsx +++ b/src/components/settings/ai/runtime-actions-row.tsx @@ -245,6 +245,7 @@ export function RuntimeActionsRow({

diff --git a/src/components/targets/target-edit-sheet.tsx b/src/components/targets/target-edit-sheet.tsx index 27df912ae..9edd6110a 100644 --- a/src/components/targets/target-edit-sheet.tsx +++ b/src/components/targets/target-edit-sheet.tsx @@ -307,6 +307,52 @@ function TargetEditSheetBody({ const canSave = !isDerivedMetric && primaryValid && secondaryValid && !busy; + // Per-field validity so the inputs can carry `aria-invalid` and the + // disabled Save button gets a spoken explanation instead of greying out + // silently. The order check (min < max) flags both ends of the pair. + const primaryOrderInvalid = + Number.isFinite(minNum) && Number.isFinite(maxNum) && minNum >= maxNum; + const primaryMinInvalid = + primaryBounds != null && + (!Number.isFinite(minNum) || + minNum < primaryBounds.min || + minNum > primaryBounds.max || + primaryOrderInvalid); + const primaryMaxInvalid = + primaryBounds != null && + (!Number.isFinite(maxNum) || + maxNum < primaryBounds.min || + maxNum > primaryBounds.max || + primaryOrderInvalid); + + const secondaryOrderInvalid = + isBp && + Number.isFinite(diaMinNum) && + Number.isFinite(diaMaxNum) && + diaMinNum >= diaMaxNum; + const secondaryMinInvalid = + isBp && + secondaryBounds != null && + (!Number.isFinite(diaMinNum) || + diaMinNum < secondaryBounds.min || + diaMinNum > secondaryBounds.max || + secondaryOrderInvalid); + const secondaryMaxInvalid = + isBp && + secondaryBounds != null && + (!Number.isFinite(diaMaxNum) || + diaMaxNum < secondaryBounds.min || + diaMaxNum > secondaryBounds.max || + secondaryOrderInvalid); + + let validationMessage: string | null = null; + if (!isDerivedMetric && primaryBounds && (!primaryValid || !secondaryValid)) { + validationMessage = + primaryOrderInvalid || secondaryOrderInvalid + ? t("targets.edit.validationOrder") + : t("targets.edit.validationBounds"); + } + const handleSave = () => { if (!canSave) return; if (isBp) { @@ -426,6 +472,7 @@ function TargetEditSheetBody({ value={displayMin} onChange={(e) => setMinStr(e.target.value)} disabled={busy} + aria-invalid={primaryMinInvalid} data-slot="target-edit-min-input" />
@@ -444,6 +491,7 @@ function TargetEditSheetBody({ value={displayMax} onChange={(e) => setMaxStr(e.target.value)} disabled={busy} + aria-invalid={primaryMaxInvalid} data-slot="target-edit-max-input" />
@@ -467,6 +515,7 @@ function TargetEditSheetBody({ value={displayDiaMin} onChange={(e) => setDiaMinStr(e.target.value)} disabled={busy} + aria-invalid={secondaryMinInvalid} data-slot="target-edit-dia-min-input" /> @@ -483,6 +532,7 @@ function TargetEditSheetBody({ value={displayDiaMax} onChange={(e) => setDiaMaxStr(e.target.value)} disabled={busy} + aria-invalid={secondaryMaxInvalid} data-slot="target-edit-dia-max-input" /> @@ -496,6 +546,16 @@ function TargetEditSheetBody({ unit: primaryBounds.unit, })}

+ + {validationMessage && ( +

+ {validationMessage} +

+ )} )} diff --git a/src/components/ui/info-hint.tsx b/src/components/ui/info-hint.tsx new file mode 100644 index 000000000..671b09cec --- /dev/null +++ b/src/components/ui/info-hint.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { Info } from "lucide-react"; + +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { useTranslations } from "@/lib/i18n/context"; +import { cn } from "@/lib/utils"; + +/** + * A small, tap-reachable help affordance. Replaces the bare `title=` + * attribute, which never surfaces on touch. The trigger is a real button + * with an accessible name, so keyboard and screen-reader users reach the + * same hint a pointer user gets on hover; tapping the button focuses it, + * which opens the tooltip on touch devices. + */ +export function InfoHint({ + label, + className, +}: { + /** The hint text shown inside the tooltip. */ + label: string; + className?: string; +}) { + const { t } = useTranslations(); + return ( + + + + + + + {label} + + + + ); +} diff --git a/src/components/ui/password-strength.tsx b/src/components/ui/password-strength.tsx index 03a67e15c..32011560f 100644 --- a/src/components/ui/password-strength.tsx +++ b/src/components/ui/password-strength.tsx @@ -10,12 +10,16 @@ interface PasswordStrengthProps { minLength?: number; } +// The bar fill stays vivid (it's a non-text indicator), but the label text +// routes through the AA-tuned semantic tokens — the raw Tailwind 500 tones +// failed contrast against the white card. Weakest is destructive, the middle +// band warning, the strong band success. const SCORE_COLORS = [ - { color: "bg-red-500", textColor: "text-red-500" }, - { color: "bg-orange-500", textColor: "text-orange-500" }, - { color: "bg-yellow-500", textColor: "text-yellow-500" }, - { color: "bg-green-500", textColor: "text-green-500" }, - { color: "bg-emerald-600", textColor: "text-emerald-600" }, + { color: "bg-red-500", textColor: "text-destructive" }, + { color: "bg-orange-500", textColor: "text-destructive" }, + { color: "bg-yellow-500", textColor: "text-warning" }, + { color: "bg-green-500", textColor: "text-success" }, + { color: "bg-emerald-600", textColor: "text-success" }, ]; export function PasswordStrength({