Merge branch 'wave/v125-ux' into release/v1.25.0

This commit is contained in:
Marc-André Bombeck committed 2026-06-28 14:41:21 +02:00
commit 76d83ebb67
34 files changed
+982 -278

No files matched your search

+32 -6
View File
@@ -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"
}
}
}
+32 -6
View File
@@ -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"
}
}
}
+32 -6
View File
@@ -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"
}
}
}
+32 -6
View File
@@ -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"
}
}
}
+32 -6
View File
@@ -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"
}
}
}
+32 -6
View File
@@ -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ć"
}
}
}
+7 -1
View File
@@ -194,7 +194,13 @@ export default function RegisterPage() {
<p className="text-muted-foreground text-center text-xs">
{t("auth.hasAccount")}{" "}
<Link href="/auth/login" className="text-primary hover:underline">
<Link
href="/auth/login"
// axe-core `link-in-text-block` requires a visible distinction
// beyond colour alone (WCAG 1.4.1). The underline stays on
// always, matching the login page's register link.
className="text-primary underline"
>
{t("auth.login")}
</Link>
</p>
+1 -1
View File
@@ -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. */}
@@ -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";
+127 -67
View File
@@ -467,74 +467,134 @@ export function BackupsSection() {
/>
</div>
) : (
<div className="mt-4 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-muted-foreground border-b text-xs">
<th className="px-3 py-2 text-left font-medium">
{t("admin.section.backups.colUser")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("admin.section.backups.colType")}
</th>
<th className="px-3 py-2 text-right font-medium">
{t("admin.section.backups.colSize")}
</th>
<th className="px-3 py-2 text-right font-medium">
{t("admin.section.backups.colCreatedAt")}
</th>
<th className="px-3 py-2 text-right font-medium">
{t("admin.section.backups.colActions")}
</th>
</tr>
</thead>
<tbody className="divide-border divide-y">
{rows.map((row, i) => (
<tr key={row.id} className={i % 2 === 0 ? "bg-muted/30" : ""}>
<td className="px-3 py-2 font-medium">{row.username}</td>
<td className="px-3 py-2">
<Badge variant="secondary" className="text-xs">
{formatBackupType(row.type, t)}
</Badge>
</td>
<td className="px-3 py-2 text-right font-mono text-xs tabular-nums">
{formatBytes(row.sizeBytes, fmt)}
</td>
<td className="text-muted-foreground px-3 py-2 text-right text-xs whitespace-nowrap">
{fmt.dateTime(row.createdAt)}
</td>
<td className="px-3 py-2 text-right whitespace-nowrap">
<div className="flex items-center justify-end gap-2">
<Button
size="sm"
variant="outline"
disabled={downloadingId === row.id}
onClick={() => handleDownload(row)}
aria-label={t("admin.section.backups.downloadAria", {
username: row.username,
})}
className="min-h-11"
>
{downloadingId === row.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Download className="h-3.5 w-3.5" />
)}
{t("admin.section.backups.download")}
</Button>
<RestoreRowDialog
row={row}
pending={
restore.isPending && restore.variables?.id === row.id
}
onConfirm={() => restore.mutate(row)}
/>
</div>
</td>
<div className="mt-4">
{/* 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. */}
<div className="hidden overflow-x-auto md:block">
<table className="w-full text-sm">
<thead>
<tr className="text-muted-foreground border-b text-xs">
<th className="px-3 py-2 text-left font-medium">
{t("admin.section.backups.colUser")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("admin.section.backups.colType")}
</th>
<th className="px-3 py-2 text-right font-medium">
{t("admin.section.backups.colSize")}
</th>
<th className="px-3 py-2 text-right font-medium">
{t("admin.section.backups.colCreatedAt")}
</th>
<th className="px-3 py-2 text-right font-medium">
{t("admin.section.backups.colActions")}
</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody className="divide-border divide-y">
{rows.map((row, i) => (
<tr key={row.id} className={i % 2 === 0 ? "bg-muted/30" : ""}>
<td className="px-3 py-2 font-medium">{row.username}</td>
<td className="px-3 py-2">
<Badge variant="secondary" className="text-xs">
{formatBackupType(row.type, t)}
</Badge>
</td>
<td className="px-3 py-2 text-right font-mono text-xs tabular-nums">
{formatBytes(row.sizeBytes, fmt)}
</td>
<td className="text-muted-foreground px-3 py-2 text-right text-xs whitespace-nowrap">
{fmt.dateTime(row.createdAt)}
</td>
<td className="px-3 py-2 text-right whitespace-nowrap">
<div className="flex items-center justify-end gap-2">
<Button
size="sm"
variant="outline"
disabled={downloadingId === row.id}
onClick={() => handleDownload(row)}
aria-label={t("admin.section.backups.downloadAria", {
username: row.username,
})}
className="min-h-11"
>
{downloadingId === row.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Download className="h-3.5 w-3.5" />
)}
{t("admin.section.backups.download")}
</Button>
<RestoreRowDialog
row={row}
pending={
restore.isPending &&
restore.variables?.id === row.id
}
onConfirm={() => restore.mutate(row)}
/>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* 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. */}
<ul
className="space-y-2 md:hidden"
data-testid="admin-backups-mobile-list"
>
{rows.map((row) => (
<li
key={row.id}
className="bg-muted/30 border-border rounded-lg border p-3"
>
<div className="flex items-center gap-2">
<span className="truncate font-medium">{row.username}</span>
<Badge variant="secondary" className="text-[10px]">
{formatBackupType(row.type, t)}
</Badge>
</div>
<p className="text-muted-foreground text-[11px]">
{t("admin.section.backups.colSize")}:{" "}
{formatBytes(row.sizeBytes, fmt)} ·{" "}
{fmt.dateTime(row.createdAt)}
</p>
<div className="mt-2 flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="outline"
disabled={downloadingId === row.id}
onClick={() => handleDownload(row)}
aria-label={t("admin.section.backups.downloadAria", {
username: row.username,
})}
className="min-h-11"
>
{downloadingId === row.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Download className="h-3.5 w-3.5" />
)}
{t("admin.section.backups.download")}
</Button>
<RestoreRowDialog
row={row}
pending={
restore.isPending && restore.variables?.id === row.id
}
onConfirm={() => restore.mutate(row)}
/>
</div>
</li>
))}
</ul>
<p className="text-muted-foreground mt-2 text-xs">
{t("admin.section.backups.retentionLabel", {
days: data!.retentionDays,
@@ -68,6 +68,7 @@ export function GeneralSettingsSection() {
</p>
</div>
<NativeSelect
aria-label={t("admin.defaultLanguage")}
value={settings?.defaultLocale ?? "de"}
onChange={(e) =>
updateSettings.mutate({ defaultLocale: e.target.value })
@@ -94,6 +95,7 @@ export function GeneralSettingsSection() {
</p>
</div>
<NativeSelect
aria-label={t("admin.defaultUserTimezone")}
value={currentDefaultTz}
onChange={(e) =>
updateSettings.mutate({ defaultUserTimezone: e.target.value })
@@ -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("<HealthChart> — 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(
<I18nProvider initialLocale="de" initialMessages={deMessages}>
<HealthChart types={["PULSE"]} title="Pulse" unit="bpm" />
</I18nProvider>,
);
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");
});
});
+28 -13
View File
@@ -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<HTMLDivElement | null>(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({
<svg
width={svgWidth}
height={svgHeight}
role="img"
aria-label={summaryLabel}
// v1.15.3 — `max-w-full` (not `w-full`) so a short window keeps its
// natural, square, left-aligned grid (the capped cells already size
// off `containerWidth`, so a full window still fills the row) rather
@@ -330,7 +337,7 @@ export function ComplianceHeatmap({
const timingInfo = hasTimingData
? ` | ${cell.data.onTime ?? 0} ${t("charts.heatmapOnTime")}, ${cell.data.late ?? 0} ${t("charts.heatmapLate")}, ${cell.data.veryLate ?? 0} ${t("charts.heatmapVeryLate")}`
: "";
return `${formatDateDE(cell.dateKey)}: ${cell.data.taken}/${cell.data.expected} (${rate}%)${timingInfo}`;
return `${formatDay(cell.dateKey)}: ${cell.data.taken}/${cell.data.expected} (${rate}%)${timingInfo}`;
};
return (
<rect
@@ -376,7 +383,15 @@ export function ComplianceHeatmap({
{tooltip && (
<div
className="bg-popover text-popover-foreground border-border pointer-events-none fixed z-50 rounded-md border px-2 py-1 text-xs shadow-md"
style={{ left: tooltip.x + 10, top: tooltip.y - 30 }}
// Clamp the left edge so a tap near the right border doesn't push
// the pinned label off-screen on a narrow viewport.
style={{
left:
typeof window !== "undefined"
? Math.min(tooltip.x + 10, window.innerWidth - 180 - 8)
: tooltip.x + 10,
top: tooltip.y - 30,
}}
>
{tooltip.text}
</div>
+50 -1
View File
@@ -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({
</div>
) : null}
<div className="relative z-10 h-full touch-pan-y">
<div
className="relative z-10 h-full touch-pan-y"
role="img"
aria-label={chartAriaLabel}
>
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={chartDataWithCompare ?? chartData}
@@ -338,6 +338,47 @@ export function MedicationComplianceChart({
const yAxisFormatter = (value: number) => `${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({
/>
)
) : (
<div className="h-[var(--chart-height,240px)] touch-pan-y md:h-[var(--chart-height-md,280px)]">
<div
className="h-[var(--chart-height,240px)] touch-pan-y md:h-[var(--chart-height-md,280px)]"
role="img"
aria-label={complianceAriaLabel}
>
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={chartData}
+35
View File
@@ -616,6 +616,39 @@ export function MoodChart({
// below the "Stimmung" heading. Aligning the STRUCTURE — not just
// the padding values — makes the three trend cards share one title
// baseline and one height. Full (non-mini) mode keeps the Card path.
// Accessibility — a concise spoken summary of the visible mood line so a
// screen reader announces the score range, the latest value and the
// direction rather than an unlabelled graphic.
const moodAriaLabel = (() => {
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}
>
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
+29 -9
View File
@@ -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";
import { moodLabelKeyForScore } from "@/lib/mood/labels";
/**
@@ -55,17 +55,17 @@ function getColor(score: number | null): string {
return "var(--dracula-green)";
}
function formatDateDE(dateStr: string): string {
const d = new Date(dateStr + "T00:00:00Z");
return `${d.getUTCDate()}.${d.getUTCMonth() + 1}.${d.getUTCFullYear()}`;
}
export function MoodHeatmap({
cells: cellData,
days = 90,
stretch = false,
}: MoodHeatmapProps) {
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<HTMLDivElement | null>(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({
<svg
width={svgWidth}
height={svgHeight}
role="img"
aria-label={summaryLabel}
// v1.15.3 — `max-w-full` (not `w-full`) so a short window keeps its
// natural, square, left-aligned grid rather than CSS-stretching a few
// capped columns into wide rectangles. Mirrors `compliance-heatmap`.
@@ -254,13 +266,13 @@ export function MoodHeatmap({
{cells.map((cell) => {
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 (
<rect
@@ -306,7 +318,15 @@ export function MoodHeatmap({
{tooltip && (
<div
className="bg-popover text-popover-foreground border-border pointer-events-none fixed z-50 rounded-md border px-2 py-1 text-xs shadow-md"
style={{ left: tooltip.x + 10, top: tooltip.y - 30 }}
// Clamp the left edge so a tap near the right border doesn't push
// the pinned label off-screen on a narrow viewport.
style={{
left:
typeof window !== "undefined"
? Math.min(tooltip.x + 10, window.innerWidth - 180 - 8)
: tooltip.x + 10,
top: tooltip.y - 30,
}}
>
{tooltip.text}
</div>
@@ -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<T extends Record<string, number>>({
tooltipFormatter,
height = 250,
}: ScatterCorrelationChartProps<T>) {
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 `<sm` keeps the
@@ -100,6 +117,8 @@ export function ScatterCorrelationChart<T extends Record<string, number>>({
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}
>
<ResponsiveContainer width="100%" height="100%">
<ScatterChart margin={{ top: 10, right: 20, bottom: 36, left: 12 }}>
+3 -3
View File
@@ -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"
>
<X className="size-2.5" aria-hidden="true" />
</button>
@@ -1245,7 +1245,7 @@ function SymptomChip({
aria-label={t("cycle.sheet.severityLevel", { level: lvl })}
onClick={() => onSeverity(symptomKey, lvl)}
className={cn(
"focus-visible:ring-ring/50 size-6 rounded-full border text-xs tabular-nums transition-colors focus-visible:ring-2 focus-visible:outline-none",
"focus-visible:ring-ring/50 grid size-11 place-items-center rounded-full border text-xs tabular-nums transition-colors focus-visible:ring-2 focus-visible:outline-none sm:size-8",
severity === lvl
? "border-primary bg-primary/15 text-primary font-semibold"
: "border-border text-muted-foreground hover:bg-accent",
+31 -2
View File
@@ -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() {
<Skeleton className="h-32 w-full" />
<Skeleton className="h-32 w-full" />
</div>
) : isError ? (
<div
role="alert"
data-slot="illness-list-error"
className="text-muted-foreground flex flex-col items-start gap-3 py-8 text-sm sm:flex-row sm:items-center sm:justify-between"
>
<span>{t("illness.listLoadError")}</span>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => void refetch()}
className="gap-1.5"
>
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t("common.retry")}</span>
</Button>
</div>
) : hasEpisodes ? (
<>
{/* Retrospective summary sits above the list so a long history
+3 -3
View File
@@ -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",
@@ -234,7 +234,7 @@ export function CoachDrawerBody({
{/* Composer pinned to the bottom. */}
<div
data-slot="coach-drawer-composer"
className="border-border/70 flex flex-col gap-2 border-t p-3 sm:p-4"
className="border-border/70 flex flex-col gap-2 border-t p-3 pb-[calc(env(safe-area-inset-bottom,0px)+0.75rem)] sm:p-4 sm:pb-[calc(env(safe-area-inset-bottom,0px)+1rem)]"
>
{composer}
</div>
@@ -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 (
<div className="space-y-0.5">
<dt className="text-muted-foreground text-xs" title={help}>
<dt className="text-muted-foreground flex items-center gap-1 text-xs">
{label}
<InfoHint label={help} />
</dt>
<dd className="text-foreground text-base font-semibold tabular-nums">
{value}
@@ -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 ? (
<div className="space-y-0.5" data-slot="glucose-cv">
<span
className="text-muted-foreground flex items-center gap-1 text-xs"
title={t("insights.bloodGlucose.clinical.cv.help")}
>
<span className="text-muted-foreground flex items-center gap-1 text-xs">
{t("insights.bloodGlucose.clinical.cv.label")}
<InfoHint label={t("insights.bloodGlucose.clinical.cv.help")} />
</span>
<div className="flex items-center gap-1.5">
<span className="text-foreground text-lg font-semibold tabular-nums">
@@ -283,12 +282,10 @@ function Metric({
}) {
return (
<div className="space-y-0.5">
<span
className="text-muted-foreground flex items-center gap-1 text-xs"
title={help}
>
<span className="text-muted-foreground flex items-center gap-1 text-xs">
{icon}
{label}
{help ? <InfoHint label={help} /> : null}
</span>
<span className="text-foreground flex items-baseline gap-1">
<span className="text-lg font-semibold tabular-nums">{value}</span>
+1 -1
View File
@@ -165,7 +165,7 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
if (isError) {
return (
<p className="text-destructive py-8 text-center text-sm">
<p role="alert" className="text-destructive py-8 text-center text-sm">
{t("labs.loadError")}
</p>
);
@@ -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({
</div>
)}
{!isLoading && (reminders?.length ?? 0) === 0 && (
{!isLoading && isError && (
<div
role="alert"
data-slot="vorsorge-error"
className="text-muted-foreground flex flex-col items-start gap-3 py-8 text-sm sm:flex-row sm:items-center sm:justify-between"
>
<span>{t("measurementReminders.loadError")}</span>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => void refetch()}
className="gap-1.5"
>
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t("common.retry")}</span>
</Button>
</div>
)}
{!isLoading && !isError && (reminders?.length ?? 0) === 0 && (
<EmptyState
icon={<Plus className="size-6" />}
title={t("measurementReminders.empty.title")}
+105 -101
View File
@@ -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"
/>
<span className="min-w-0 flex-1 truncate text-sm" title={name}>
<span className="min-w-[8rem] flex-1 truncate text-sm" title={name}>
{name}
</span>
{tag.kind === "RATED" && (
@@ -536,106 +536,110 @@ function SortableTagRow({
{t("mood.manage.usageCount", { count: String(tag.usageCount) })}
</Badge>
)}
<Button
type="button"
variant="ghost"
size="icon"
className="size-11 sm:size-9"
onClick={() => onToggleVisibility(tag)}
disabled={disabled}
aria-pressed={tag.custom ? undefined : isHidden}
aria-label={`${
tag.custom
? t("mood.manage.archive")
: isHidden
? t("mood.manage.showTag")
: t("mood.manage.hideTag")
} — ${name}`}
title={
tag.custom
? t("mood.manage.archive")
: isHidden
? t("mood.manage.showTag")
: t("mood.manage.hideTag")
}
>
{tag.custom ? (
<Archive className="h-4 w-4" aria-hidden="true" />
) : isHidden ? (
<EyeOff className="h-4 w-4" aria-hidden="true" />
) : (
<Eye className="h-4 w-4" aria-hidden="true" />
)}
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-11 sm:size-9"
onClick={() => onMove(groupKey, tag.key, -1)}
disabled={index === 0 || disabled}
aria-label={`${t("mood.manage.moveUp")} — ${name}`}
>
<ArrowUp className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-11 sm:size-9"
onClick={() => onMove(groupKey, tag.key, 1)}
disabled={index === total - 1 || disabled}
aria-label={`${t("mood.manage.moveDown")} — ${name}`}
>
<ArrowDown className="h-4 w-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="size-11 sm:size-9"
aria-label={`${t("common.moreOptions")} — ${name}`}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{tag.custom && (
<DropdownMenuItem onClick={() => onEdit(tag)}>
<Pencil className="mr-2 h-4 w-4" />
{t("common.edit")}
</DropdownMenuItem>
{/* The action cluster wraps to its own line on a narrow row so the
tag name keeps a readable floor instead of truncating away. */}
<div className="ml-auto flex shrink-0 items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="size-11 sm:size-9"
onClick={() => onToggleVisibility(tag)}
disabled={disabled}
aria-pressed={tag.custom ? undefined : isHidden}
aria-label={`${
tag.custom
? t("mood.manage.archive")
: isHidden
? t("mood.manage.showTag")
: t("mood.manage.hideTag")
} — ${name}`}
title={
tag.custom
? t("mood.manage.archive")
: isHidden
? t("mood.manage.showTag")
: t("mood.manage.hideTag")
}
>
{tag.custom ? (
<Archive className="h-4 w-4" aria-hidden="true" />
) : isHidden ? (
<EyeOff className="h-4 w-4" aria-hidden="true" />
) : (
<Eye className="h-4 w-4" aria-hidden="true" />
)}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<FolderInput className="mr-2 h-4 w-4" />
{t("mood.manage.moveToGroup")}
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
{groupOptions
.filter((group) => group.key !== groupKey)
.map((group) => (
<DropdownMenuItem
key={group.key}
onClick={() => onMoveToGroup(tag, group.key)}
>
{group.name}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
{tag.custom && (
<DropdownMenuItem onClick={() => onToggleVisibility(tag)}>
<Archive className="mr-2 h-4 w-4" />
{t("mood.manage.archive")}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-11 sm:size-9"
onClick={() => onMove(groupKey, tag.key, -1)}
disabled={index === 0 || disabled}
aria-label={`${t("mood.manage.moveUp")} — ${name}`}
>
<ArrowUp className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-11 sm:size-9"
onClick={() => onMove(groupKey, tag.key, 1)}
disabled={index === total - 1 || disabled}
aria-label={`${t("mood.manage.moveDown")} — ${name}`}
>
<ArrowDown className="h-4 w-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="size-11 sm:size-9"
aria-label={`${t("common.moreOptions")} — ${name}`}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{tag.custom && (
<DropdownMenuItem onClick={() => onEdit(tag)}>
<Pencil className="mr-2 h-4 w-4" />
{t("common.edit")}
</DropdownMenuItem>
)}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<FolderInput className="mr-2 h-4 w-4" />
{t("mood.manage.moveToGroup")}
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
{groupOptions
.filter((group) => group.key !== groupKey)
.map((group) => (
<DropdownMenuItem
key={group.key}
onClick={() => onMoveToGroup(tag, group.key)}
>
{group.name}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
{tag.custom && (
<DropdownMenuItem onClick={() => onToggleVisibility(tag)}>
<Archive className="mr-2 h-4 w-4" />
{t("mood.manage.archive")}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
}
+20 -1
View File
@@ -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 = {}) {
<div className="flex h-32 items-center justify-center">
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
</div>
) : isError ? (
<div
role="alert"
data-slot="mood-list-error"
className="text-muted-foreground flex flex-col items-start gap-3 py-8 text-sm sm:flex-row sm:items-center sm:justify-between"
>
<span>{t("mood.loadError")}</span>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => void refetch()}
className="gap-1.5"
>
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t("common.retry")}</span>
</Button>
</div>
) : !data?.entries?.length ? (
// v1.4.15 phase-C5: replace bare-text empty rectangle with
// EmptyState. Filter-aware copy splits "no mood entries yet"
@@ -245,6 +245,7 @@ export function RuntimeActionsRow({
</p>
</div>
<Switch
aria-label={t("settings.rawData")}
checked={privacyMode === "raw"}
onCheckedChange={togglePrivacy}
/>
@@ -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"
/>
</div>
@@ -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"
/>
</div>
@@ -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"
/>
</div>
@@ -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"
/>
</div>
@@ -496,6 +546,16 @@ function TargetEditSheetBody({
unit: primaryBounds.unit,
})}
</p>
{validationMessage && (
<p
role="alert"
data-slot="target-edit-validation"
className="text-destructive text-xs"
>
{validationMessage}
</p>
)}
</div>
)}
</form>
+56
View File
@@ -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 (
<TooltipProvider delayDuration={150}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
data-slot="info-hint"
aria-label={t("common.moreInfo")}
className={cn(
"text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 -m-1 inline-flex min-h-6 min-w-6 shrink-0 items-center justify-center rounded-full p-1 transition-colors focus-visible:ring-2 focus-visible:outline-none",
className,
)}
>
<Info className="size-3.5" aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent
data-slot="info-hint-body"
align="start"
className="max-w-xs leading-relaxed"
>
{label}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
+9 -5
View File
@@ -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({
+1 -1
View File
@@ -180,7 +180,7 @@ export function ResponsiveSheet({
{footer ? (
<SheetFooter
data-slot="responsive-sheet-footer"
className="border-border/70 bg-background/95 supports-[backdrop-filter]:bg-background/80 sticky bottom-0 mt-0 flex-row justify-end gap-2 border-t p-4 backdrop-blur"
className="border-border/70 bg-background/95 supports-[backdrop-filter]:bg-background/80 sticky bottom-0 mt-0 flex-row justify-end gap-2 border-t p-4 pb-[calc(env(safe-area-inset-bottom,0px)+1rem)] backdrop-blur"
>
{footer}
</SheetFooter>
+40 -14
View File
@@ -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<void> {
}
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<string | null>(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;
}