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ć" + } } } 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/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/__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/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. */} + +

{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/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} > 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" >
+ ) : isError ? ( +
+ {t("illness.listLoadError")} + +
) : hasEpisodes ? ( <> {/* Retrospective summary sits above the list so a long history diff --git a/src/components/illness/log-day-sheet.tsx b/src/components/illness/log-day-sheet.tsx index 1f6a740e8..0afacddc7 100644 --- a/src/components/illness/log-day-sheet.tsx +++ b/src/components/illness/log-day-sheet.tsx @@ -60,7 +60,7 @@ function Chip({ aria-pressed={active} onClick={onClick} className={cn( - "rounded-full border px-3 py-1.5 text-sm transition-colors", + "inline-flex min-h-11 items-center rounded-full border px-3 py-1.5 text-sm transition-colors sm:min-h-8", active ? "border-primary bg-primary/10 text-foreground" : "border-border text-muted-foreground hover:bg-muted", @@ -95,7 +95,7 @@ function SymptomChip({ aria-pressed={active} onClick={onToggle} className={cn( - "flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm transition-colors", + "flex min-h-11 items-center gap-2 rounded-full border px-3 py-1.5 text-sm transition-colors sm:min-h-8", active ? "border-primary bg-primary/10 text-foreground" : "border-border text-muted-foreground hover:bg-muted", @@ -117,7 +117,7 @@ function SymptomChip({ aria-pressed={severity === value} onClick={() => onSeverity(value)} className={cn( - "h-6 w-6 rounded-md border text-xs", + "grid h-11 w-11 place-items-center rounded-md border text-xs sm:h-8 sm:w-8", severity === value ? "border-primary bg-primary/10 text-foreground" : "border-border text-muted-foreground hover:bg-muted", diff --git a/src/components/insights/coach-panel/coach-drawer-body.tsx b/src/components/insights/coach-panel/coach-drawer-body.tsx index 3933b2fe4..4263ecaa3 100644 --- a/src/components/insights/coach-panel/coach-drawer-body.tsx +++ b/src/components/insights/coach-panel/coach-drawer-body.tsx @@ -234,7 +234,7 @@ export function CoachDrawerBody({ {/* Composer pinned to the bottom. */}
{composer}
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/manage/tag-manager-card.tsx b/src/components/mood/manage/tag-manager-card.tsx index 1e7a25fa6..34f37066f 100644 --- a/src/components/mood/manage/tag-manager-card.tsx +++ b/src/components/mood/manage/tag-manager-card.tsx @@ -502,7 +502,7 @@ function SortableTagRow({ data-tag={tag.key} data-hidden={isHidden ? "true" : undefined} data-dragging={isDragging ? "true" : undefined} - className={`border-border bg-background/30 flex min-h-12 items-center gap-2 rounded-md border px-3 py-2 ${ + className={`border-border bg-background/30 flex min-h-12 flex-wrap items-center gap-2 rounded-md border px-3 py-2 ${ isDragging ? "ring-primary z-10 opacity-90 shadow-lg ring-2" : "" } ${isHidden ? "opacity-60" : ""}`} > @@ -523,7 +523,7 @@ function SortableTagRow({ className="text-muted-foreground h-4 w-4 shrink-0" aria-hidden="true" /> - + {name} {tag.kind === "RATED" && ( @@ -536,106 +536,110 @@ function SortableTagRow({ {t("mood.manage.usageCount", { count: String(tag.usageCount) })} )} - - - - - - - - - {tag.custom && ( - onEdit(tag)}> - - {t("common.edit")} - + {/* The action cluster wraps to its own line on a narrow row so the + tag name keeps a readable floor instead of truncating away. */} +
+ + + + + + + + + {tag.custom && ( + onEdit(tag)}> + + {t("common.edit")} + + )} + + + + {t("mood.manage.moveToGroup")} + + + + {groupOptions + .filter((group) => group.key !== groupKey) + .map((group) => ( + onMoveToGroup(tag, group.key)} + > + {group.name} + + ))} + + + + {tag.custom && ( + onToggleVisibility(tag)}> + + {t("mood.manage.archive")} + + )} + + +
); } 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({ 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} 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; }