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({
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({