From dd6b0867a4265d43e29c3bce07bafb14b0908645 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 18:29:26 +0200 Subject: [PATCH 1/8] i18n: translate illness journal strings and add v1.25 UI strings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The illness journal copy (load error, delete confirmation, edit/menu labels, parent links, and the vital-sign labels) shipped in English in every locale; translate it into de/es/fr/it/pl. Also add the new strings the v1.25 surfaces introduce — the biomarker 'load more readings' action, the environment city-search zero-result message, and an accessible label for the document upload file input — across all six locales. --- messages/de.json | 43 +++++++++++++++++++++++-------------------- messages/en.json | 9 ++++++--- messages/es.json | 43 +++++++++++++++++++++++-------------------- messages/fr.json | 43 +++++++++++++++++++++++-------------------- messages/it.json | 43 +++++++++++++++++++++++-------------------- messages/pl.json | 43 +++++++++++++++++++++++-------------------- 6 files changed, 121 insertions(+), 103 deletions(-) diff --git a/messages/de.json b/messages/de.json index dc0e00086..621accc32 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4317,7 +4317,8 @@ "title": "Vergangene Tage nacherfassen", "description": "Rufe Wetter für einen vergangenen Zeitraum ab und speichere es, damit ältere Tage in die Korrelationen einfließen. Der Start steht standardmäßig auf dem Gültigkeitsdatum deines Wohnorts; um weiter zurückzureichen, füge zuerst einen Ortszeitraum für diesen Bereich hinzu.", "run": "Nacherfassen" - } + }, + "noResults": "Keine passenden Orte gefunden." } }, "profile": "Profil", @@ -7201,7 +7202,8 @@ "fileType": "Lade ein JPEG, PNG, WebP oder PDF hoch.", "extractFailed": "Der Befund konnte nicht gelesen werden. Versuche ein schärferes Foto.", "pdfNeedsAnthropic": "PDF-Scannen benötigt einen Claude-Bildanbieter; lade stattdessen ein Foto hoch." - } + }, + "loadMoreReadings": "Weitere Werte laden" }, "modules": { "cycle": { @@ -7319,8 +7321,8 @@ "note": "Notiz", "notePlaceholder": "Was erwähnenswert ist (verschlüsselt).", "saveError": "Die Episode konnte nicht gespeichert werden. Bitte erneut versuchen.", - "parent": "Part of", - "parentNone": "Not linked" + "parent": "Teil von", + "parentNone": "Nicht verknüpft" }, "sheet": { "title": "{date} erfassen", @@ -7401,32 +7403,32 @@ "fever": "Temperatur: {value} °C", "empty": "Noch keine Tage erfasst." }, - "loadError": "Could not load this episode.", - "backToList": "Back to journal", + "loadError": "Diese Episode konnte nicht geladen werden.", + "backToList": "Zurück zum Journal", "detail": { "note": "Note" }, "edit": { - "title": "Edit episode", - "description": "Update this episode's details." + "title": "Episode bearbeiten", + "description": "Aktualisiere die Details dieser Episode." }, "menu": { - "label": "Episode actions" + "label": "Episodenaktionen" }, "deleteConfirm": { - "title": "Delete this episode?", - "body": "“{label}” and all its day-logs will be removed. This cannot be undone." + "title": "Diese Episode löschen?", + "body": "„{label}“ und alle zugehörigen Tageseinträge werden entfernt. Dies kann nicht rückgängig gemacht werden." }, "vital": { - "RESTING_HEART_RATE": "Resting heart rate", - "HEART_RATE_VARIABILITY": "Heart-rate variability", + "RESTING_HEART_RATE": "Ruheherzfrequenz", + "HEART_RATE_VARIABILITY": "Herzfrequenzvariabilität", "RECOVERY_SCORE": "Recovery", - "OXYGEN_SATURATION": "Oxygen saturation", - "BODY_TEMPERATURE": "Body temperature", - "SKIN_TEMPERATURE": "Skin temperature", - "RESPIRATORY_RATE": "Respiratory rate", - "BLOOD_PRESSURE_SYS": "Systolic blood pressure", - "BLOOD_PRESSURE_DIA": "Diastolic blood pressure", + "OXYGEN_SATURATION": "Sauerstoffsättigung", + "BODY_TEMPERATURE": "Körpertemperatur", + "SKIN_TEMPERATURE": "Hauttemperatur", + "RESPIRATORY_RATE": "Atemfrequenz", + "BLOOD_PRESSURE_SYS": "Systolischer Blutdruck", + "BLOOD_PRESSURE_DIA": "Diastolischer Blutdruck", "PULSE": "Pulse", "WEIGHT": "Weight" }, @@ -7741,7 +7743,8 @@ "label": "Dokument hochladen", "kindLabel": "Dokumenttyp", "button": "Hochladen", - "uploading": "Wird gelesen …" + "uploading": "Wird gelesen …", + "fileLabel": "Dokumentdatei" }, "kind": { "DOCTOR_REPORT": "Arztbericht", diff --git a/messages/en.json b/messages/en.json index e6e776db1..557f2e415 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4317,7 +4317,8 @@ "title": "Backfill past days", "description": "Fetch and store weather for a past date range so older days join the correlations. The start defaults to your home's effective date; to reach further back, add a location period for that range first.", "run": "Backfill" - } + }, + "noResults": "No matching places found." } }, "profile": "Profile", @@ -7201,7 +7202,8 @@ "fileType": "Upload a JPEG, PNG, WebP, or PDF.", "extractFailed": "Couldn't read the report. Try a clearer photo.", "pdfNeedsAnthropic": "PDF scanning needs a Claude vision provider; upload a photo instead." - } + }, + "loadMoreReadings": "Load more readings" }, "modules": { "cycle": { @@ -7741,7 +7743,8 @@ "label": "Upload a document", "kindLabel": "Document type", "button": "Upload", - "uploading": "Reading…" + "uploading": "Reading…", + "fileLabel": "Document file" }, "kind": { "DOCTOR_REPORT": "Doctor report", diff --git a/messages/es.json b/messages/es.json index b9687c4c0..d80b581bf 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4317,7 +4317,8 @@ "title": "Rellenar días pasados", "description": "Obtén y guarda el tiempo de un rango de fechas pasado para que los días antiguos entren en las correlaciones. El inicio toma por defecto la fecha de vigencia de tu hogar; para llegar más atrás, añade primero un periodo de ubicación para ese rango.", "run": "Rellenar" - } + }, + "noResults": "No se encontraron lugares coincidentes." } }, "profile": "Perfil", @@ -7201,7 +7202,8 @@ "fileType": "Sube un JPEG, PNG, WebP o PDF.", "extractFailed": "No se pudo leer el informe. Prueba con una foto más nítida.", "pdfNeedsAnthropic": "El escaneo de PDF necesita un proveedor de visión de Claude; sube una foto en su lugar." - } + }, + "loadMoreReadings": "Cargar más mediciones" }, "modules": { "cycle": { @@ -7319,8 +7321,8 @@ "note": "Nota", "notePlaceholder": "Algo que valga la pena recordar (cifrado).", "saveError": "No se pudo guardar el episodio. Inténtalo de nuevo.", - "parent": "Part of", - "parentNone": "Not linked" + "parent": "Parte de", + "parentNone": "Sin vincular" }, "sheet": { "title": "Registrar {date}", @@ -7401,32 +7403,32 @@ "fever": "Temperatura: {value} °C", "empty": "Todavía no hay días registrados." }, - "loadError": "Could not load this episode.", - "backToList": "Back to journal", + "loadError": "No se pudo cargar este episodio.", + "backToList": "Volver al diario", "detail": { "note": "Note" }, "edit": { - "title": "Edit episode", - "description": "Update this episode's details." + "title": "Editar episodio", + "description": "Actualiza los detalles de este episodio." }, "menu": { - "label": "Episode actions" + "label": "Acciones del episodio" }, "deleteConfirm": { - "title": "Delete this episode?", - "body": "“{label}” and all its day-logs will be removed. This cannot be undone." + "title": "¿Eliminar este episodio?", + "body": "«{label}» y todos sus registros diarios se eliminarán. Esta acción no se puede deshacer." }, "vital": { - "RESTING_HEART_RATE": "Resting heart rate", - "HEART_RATE_VARIABILITY": "Heart-rate variability", + "RESTING_HEART_RATE": "Frecuencia cardíaca en reposo", + "HEART_RATE_VARIABILITY": "Variabilidad de la frecuencia cardíaca", "RECOVERY_SCORE": "Recovery", - "OXYGEN_SATURATION": "Oxygen saturation", - "BODY_TEMPERATURE": "Body temperature", - "SKIN_TEMPERATURE": "Skin temperature", - "RESPIRATORY_RATE": "Respiratory rate", - "BLOOD_PRESSURE_SYS": "Systolic blood pressure", - "BLOOD_PRESSURE_DIA": "Diastolic blood pressure", + "OXYGEN_SATURATION": "Saturación de oxígeno", + "BODY_TEMPERATURE": "Temperatura corporal", + "SKIN_TEMPERATURE": "Temperatura de la piel", + "RESPIRATORY_RATE": "Frecuencia respiratoria", + "BLOOD_PRESSURE_SYS": "Presión arterial sistólica", + "BLOOD_PRESSURE_DIA": "Presión arterial diastólica", "PULSE": "Pulse", "WEIGHT": "Weight" }, @@ -7741,7 +7743,8 @@ "label": "Subir un documento", "kindLabel": "Tipo de documento", "button": "Subir", - "uploading": "Leyendo…" + "uploading": "Leyendo…", + "fileLabel": "Archivo del documento" }, "kind": { "DOCTOR_REPORT": "Informe médico", diff --git a/messages/fr.json b/messages/fr.json index 7df786db9..70c1ec681 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4317,7 +4317,8 @@ "title": "Récupérer les jours passés", "description": "Récupérez et enregistrez la météo d'une plage de dates passée pour que les jours anciens rejoignent les corrélations. Le début reprend par défaut la date d'effet de votre domicile ; pour remonter plus loin, ajoutez d'abord une période de localisation pour cette plage.", "run": "Récupérer" - } + }, + "noResults": "Aucun lieu correspondant trouvé." } }, "profile": "Profil", @@ -7201,7 +7202,8 @@ "fileType": "Importez un JPEG, PNG, WebP ou PDF.", "extractFailed": "Impossible de lire le compte rendu. Essayez une photo plus nette.", "pdfNeedsAnthropic": "Le scan PDF nécessite un fournisseur de vision Claude ; importez plutôt une photo." - } + }, + "loadMoreReadings": "Charger plus de mesures" }, "modules": { "cycle": { @@ -7319,8 +7321,8 @@ "note": "Note", "notePlaceholder": "Ce qui mérite d'être noté (chiffré).", "saveError": "Impossible d'enregistrer l'épisode. Veuillez réessayer.", - "parent": "Part of", - "parentNone": "Not linked" + "parent": "Fait partie de", + "parentNone": "Non lié" }, "sheet": { "title": "Enregistrer {date}", @@ -7401,32 +7403,32 @@ "fever": "Température : {value} °C", "empty": "Aucun jour enregistré pour l’instant." }, - "loadError": "Could not load this episode.", - "backToList": "Back to journal", + "loadError": "Impossible de charger cet épisode.", + "backToList": "Retour au journal", "detail": { "note": "Note" }, "edit": { - "title": "Edit episode", - "description": "Update this episode's details." + "title": "Modifier l'épisode", + "description": "Mettez à jour les détails de cet épisode." }, "menu": { - "label": "Episode actions" + "label": "Actions de l'épisode" }, "deleteConfirm": { - "title": "Delete this episode?", - "body": "“{label}” and all its day-logs will be removed. This cannot be undone." + "title": "Supprimer cet épisode ?", + "body": "« {label} » et tous ses relevés quotidiens seront supprimés. Cette action est irréversible." }, "vital": { - "RESTING_HEART_RATE": "Resting heart rate", - "HEART_RATE_VARIABILITY": "Heart-rate variability", + "RESTING_HEART_RATE": "Fréquence cardiaque au repos", + "HEART_RATE_VARIABILITY": "Variabilité de la fréquence cardiaque", "RECOVERY_SCORE": "Recovery", - "OXYGEN_SATURATION": "Oxygen saturation", - "BODY_TEMPERATURE": "Body temperature", - "SKIN_TEMPERATURE": "Skin temperature", - "RESPIRATORY_RATE": "Respiratory rate", - "BLOOD_PRESSURE_SYS": "Systolic blood pressure", - "BLOOD_PRESSURE_DIA": "Diastolic blood pressure", + "OXYGEN_SATURATION": "Saturation en oxygène", + "BODY_TEMPERATURE": "Température corporelle", + "SKIN_TEMPERATURE": "Température cutanée", + "RESPIRATORY_RATE": "Fréquence respiratoire", + "BLOOD_PRESSURE_SYS": "Pression artérielle systolique", + "BLOOD_PRESSURE_DIA": "Pression artérielle diastolique", "PULSE": "Pulse", "WEIGHT": "Weight" }, @@ -7741,7 +7743,8 @@ "label": "Importer un document", "kindLabel": "Type de document", "button": "Importer", - "uploading": "Lecture…" + "uploading": "Lecture…", + "fileLabel": "Fichier du document" }, "kind": { "DOCTOR_REPORT": "Compte rendu médical", diff --git a/messages/it.json b/messages/it.json index e4f800775..cc173e40b 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4317,7 +4317,8 @@ "title": "Recupera i giorni passati", "description": "Recupera e salva il meteo di un intervallo di date passato così i giorni più vecchi entrano nelle correlazioni. L'inizio è impostato in modo predefinito sulla data di validità della tua residenza; per andare più indietro, aggiungi prima un periodo di posizione per quell'intervallo.", "run": "Recupera" - } + }, + "noResults": "Nessun luogo corrispondente trovato." } }, "profile": "Profilo", @@ -7201,7 +7202,8 @@ "fileType": "Carica un JPEG, PNG, WebP o PDF.", "extractFailed": "Impossibile leggere il referto. Prova con una foto più nitida.", "pdfNeedsAnthropic": "La scansione PDF richiede un provider di visione Claude; carica invece una foto." - } + }, + "loadMoreReadings": "Carica altre misurazioni" }, "modules": { "cycle": { @@ -7319,8 +7321,8 @@ "note": "Nota", "notePlaceholder": "Qualcosa da ricordare (cifrato).", "saveError": "Impossibile salvare l'episodio. Riprova.", - "parent": "Part of", - "parentNone": "Not linked" + "parent": "Parte di", + "parentNone": "Non collegato" }, "sheet": { "title": "Registra {date}", @@ -7401,32 +7403,32 @@ "fever": "Temperatura: {value} °C", "empty": "Nessun giorno registrato finora." }, - "loadError": "Could not load this episode.", - "backToList": "Back to journal", + "loadError": "Impossibile caricare questo episodio.", + "backToList": "Torna al diario", "detail": { "note": "Note" }, "edit": { - "title": "Edit episode", - "description": "Update this episode's details." + "title": "Modifica episodio", + "description": "Aggiorna i dettagli di questo episodio." }, "menu": { - "label": "Episode actions" + "label": "Azioni dell'episodio" }, "deleteConfirm": { - "title": "Delete this episode?", - "body": "“{label}” and all its day-logs will be removed. This cannot be undone." + "title": "Eliminare questo episodio?", + "body": "«{label}» e tutti i relativi registri giornalieri verranno rimossi. L'operazione non può essere annullata." }, "vital": { - "RESTING_HEART_RATE": "Resting heart rate", - "HEART_RATE_VARIABILITY": "Heart-rate variability", + "RESTING_HEART_RATE": "Frequenza cardiaca a riposo", + "HEART_RATE_VARIABILITY": "Variabilità della frequenza cardiaca", "RECOVERY_SCORE": "Recovery", - "OXYGEN_SATURATION": "Oxygen saturation", - "BODY_TEMPERATURE": "Body temperature", - "SKIN_TEMPERATURE": "Skin temperature", - "RESPIRATORY_RATE": "Respiratory rate", - "BLOOD_PRESSURE_SYS": "Systolic blood pressure", - "BLOOD_PRESSURE_DIA": "Diastolic blood pressure", + "OXYGEN_SATURATION": "Saturazione di ossigeno", + "BODY_TEMPERATURE": "Temperatura corporea", + "SKIN_TEMPERATURE": "Temperatura cutanea", + "RESPIRATORY_RATE": "Frequenza respiratoria", + "BLOOD_PRESSURE_SYS": "Pressione arteriosa sistolica", + "BLOOD_PRESSURE_DIA": "Pressione arteriosa diastolica", "PULSE": "Pulse", "WEIGHT": "Weight" }, @@ -7741,7 +7743,8 @@ "label": "Carica un documento", "kindLabel": "Tipo di documento", "button": "Carica", - "uploading": "Lettura…" + "uploading": "Lettura…", + "fileLabel": "File del documento" }, "kind": { "DOCTOR_REPORT": "Referto medico", diff --git a/messages/pl.json b/messages/pl.json index 52381c2ba..4477b6251 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4317,7 +4317,8 @@ "title": "Uzupełnij minione dni", "description": "Pobierz i zapisz pogodę dla minionego zakresu dat, aby starsze dni weszły do korelacji. Początek domyślnie przyjmuje datę obowiązywania Twojego domu; aby sięgnąć dalej wstecz, najpierw dodaj okres lokalizacji dla tego zakresu.", "run": "Uzupełnij" - } + }, + "noResults": "Nie znaleziono pasujących miejsc." } }, "profile": "Profil", @@ -7201,7 +7202,8 @@ "fileType": "Prześlij plik JPEG, PNG, WebP lub PDF.", "extractFailed": "Nie udało się odczytać wyniku. Spróbuj wyraźniejszego zdjęcia.", "pdfNeedsAnthropic": "Skanowanie PDF wymaga dostawcy wizji Claude; prześlij zamiast tego zdjęcie." - } + }, + "loadMoreReadings": "Wczytaj więcej pomiarów" }, "modules": { "cycle": { @@ -7319,8 +7321,8 @@ "note": "Notatka", "notePlaceholder": "Cokolwiek wartego zapamiętania (zaszyfrowane).", "saveError": "Nie udało się zapisać epizodu. Spróbuj ponownie.", - "parent": "Part of", - "parentNone": "Not linked" + "parent": "Część", + "parentNone": "Niepowiązane" }, "sheet": { "title": "Zapisz {date}", @@ -7401,32 +7403,32 @@ "fever": "Temperatura: {value} °C", "empty": "Nie zapisano jeszcze żadnych dni." }, - "loadError": "Could not load this episode.", - "backToList": "Back to journal", + "loadError": "Nie udało się wczytać tego epizodu.", + "backToList": "Powrót do dziennika", "detail": { "note": "Note" }, "edit": { - "title": "Edit episode", - "description": "Update this episode's details." + "title": "Edytuj epizod", + "description": "Zaktualizuj szczegóły tego epizodu." }, "menu": { - "label": "Episode actions" + "label": "Działania epizodu" }, "deleteConfirm": { - "title": "Delete this episode?", - "body": "“{label}” and all its day-logs will be removed. This cannot be undone." + "title": "Usunąć ten epizod?", + "body": "„{label}” oraz wszystkie powiązane wpisy dzienne zostaną usunięte. Tej operacji nie można cofnąć." }, "vital": { - "RESTING_HEART_RATE": "Resting heart rate", - "HEART_RATE_VARIABILITY": "Heart-rate variability", + "RESTING_HEART_RATE": "Spoczynkowe tętno", + "HEART_RATE_VARIABILITY": "Zmienność rytmu serca", "RECOVERY_SCORE": "Recovery", - "OXYGEN_SATURATION": "Oxygen saturation", - "BODY_TEMPERATURE": "Body temperature", - "SKIN_TEMPERATURE": "Skin temperature", - "RESPIRATORY_RATE": "Respiratory rate", - "BLOOD_PRESSURE_SYS": "Systolic blood pressure", - "BLOOD_PRESSURE_DIA": "Diastolic blood pressure", + "OXYGEN_SATURATION": "Saturacja tlenem", + "BODY_TEMPERATURE": "Temperatura ciała", + "SKIN_TEMPERATURE": "Temperatura skóry", + "RESPIRATORY_RATE": "Częstość oddechów", + "BLOOD_PRESSURE_SYS": "Ciśnienie skurczowe", + "BLOOD_PRESSURE_DIA": "Ciśnienie rozkurczowe", "PULSE": "Pulse", "WEIGHT": "Weight" }, @@ -7741,7 +7743,8 @@ "label": "Prześlij dokument", "kindLabel": "Typ dokumentu", "button": "Prześlij", - "uploading": "Odczytywanie…" + "uploading": "Odczytywanie…", + "fileLabel": "Plik dokumentu" }, "kind": { "DOCTOR_REPORT": "Raport lekarski", From 8e238d183431138aa36dd5bfe172e761343d664f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 18:29:33 +0200 Subject: [PATCH 2/8] a11y: lift the shared Coach icon and info-hint to the 44px touch floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The top-right Coach hand-off icon button and the info-hint trigger sat below the WCAG 2.5.5 44px minimum on touch. Grow both hit boxes to 44px on touch while keeping the visible footprint unchanged — the Coach button is a fill-less ghost, and the info-hint offsets its padding with an equal negative margin so inline layout is untouched. --- src/components/insights/ask-coach-action.tsx | 6 +++++- src/components/ui/info-hint.tsx | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/components/insights/ask-coach-action.tsx b/src/components/insights/ask-coach-action.tsx index 08aa56d33..c0e038126 100644 --- a/src/components/insights/ask-coach-action.tsx +++ b/src/components/insights/ask-coach-action.tsx @@ -146,7 +146,11 @@ export function AskCoachIconButton({ aria-label={accessibleLabel} onClick={() => launch.askCoach(question, scope, autoSend)} className={cn( - "text-muted-foreground hover:text-foreground size-8", + // Keep the quiet 32px visual on pointer surfaces, but grow the + // tap target to the WCAG 2.5.5 44px floor on touch (the ghost has + // no fill, so the larger hit box is invisible). Matches the + // header-action convention on the biomarker detail page. + "text-muted-foreground hover:text-foreground min-h-11 min-w-11 sm:size-8", className, )} > diff --git a/src/components/ui/info-hint.tsx b/src/components/ui/info-hint.tsx index 671b09cec..d3caa2de9 100644 --- a/src/components/ui/info-hint.tsx +++ b/src/components/ui/info-hint.tsx @@ -36,7 +36,11 @@ export function InfoHint({ 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", + // A 44px (WCAG 2.5.5) tap target whose visible footprint stays at + // the 24px the icon needs: the 44px box is offset by an equal + // negative margin (`p-2.5` + `-m-2.5`), so surrounding inline + // layout is unchanged while the touchable area meets the floor. + "text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 -m-2.5 inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-full p-2.5 transition-colors focus-visible:ring-2 focus-visible:outline-none", className, )} > From 38c00100d0fc0bececb2e02db0ec21c8c299727a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 18:29:39 +0200 Subject: [PATCH 3/8] fix(insights): sleep-debt explainer on the resolved card; Skeleton primitive for the narrative loader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sleep-debt computed-engine InfoHint only rendered in the still-learning state — but the resolved full-debt card is the common case and where the figure most needs the 'this is our own estimate, not a wearable's native number' context. Show it there too. Separately, swap the period-narrative loading shell's hand-rolled muted divs for the shared Skeleton primitive so its shimmer matches every other loader. --- src/components/insights/period-narrative-card.tsx | 7 ++++--- src/components/insights/sleep/sleep-debt-card.tsx | 7 +++++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/components/insights/period-narrative-card.tsx b/src/components/insights/period-narrative-card.tsx index 47ffa3cde..222401b94 100644 --- a/src/components/insights/period-narrative-card.tsx +++ b/src/components/insights/period-narrative-card.tsx @@ -9,6 +9,7 @@ import { formatUpdatedLabel } from "@/lib/i18n/relative-time"; import { queryKeys } from "@/lib/query-keys"; import { useAuth } from "@/hooks/use-auth"; import { cn } from "@/lib/utils"; +import { Skeleton } from "@/components/ui/skeleton"; import { SectionHeading } from "@/components/insights/section-heading"; import { AskCoachAction } from "@/components/insights/ask-coach-action"; import { ProseBlocks } from "@/components/insights/prose-blocks"; @@ -108,9 +109,9 @@ export function PeriodNarrativeCard({ aria-hidden="true" className={SKELETON_SHELL} > -
-
-
+ + +
); diff --git a/src/components/insights/sleep/sleep-debt-card.tsx b/src/components/insights/sleep/sleep-debt-card.tsx index db4d329cb..cb246d5d5 100644 --- a/src/components/insights/sleep/sleep-debt-card.tsx +++ b/src/components/insights/sleep/sleep-debt-card.tsx @@ -70,6 +70,13 @@ export function SleepDebtCard({ debt }: { debt: SleepDebtDto }) { {t("insights.sleep.debt.title")} + {/* v1.25.0 — the resolved full-debt card is the common case and the + place the figure most needs context: explain the COMPUTED engine + and that it differs from a wearable's native number here too, not + only in the still-learning `partial` state above. */} + {debt.source === "COMPUTED" ? ( + + ) : null}
From a3b32524f7cf5f27066a48d35f313cce28f099f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 18:29:45 +0200 Subject: [PATCH 4/8] fix(documents): skeleton loading, calmer review badge, larger touch targets The inbound-document list and review panels used centered spinners; swap them for the app's Skeleton pattern so the resolved content lands without a layout jump. The low-confidence review badge moves from destructive red to a calm amber, matching the surface's non-alarming posture. Approve / reject / edit / save controls grow to the 44px touch floor, the upload file input gains an accessible label, and each document row exposes aria-expanded. --- .../documents/inbound-documents-view.tsx | 51 +++++++++++++------ 1 file changed, 36 insertions(+), 15 deletions(-) diff --git a/src/components/documents/inbound-documents-view.tsx b/src/components/documents/inbound-documents-view.tsx index b95ec9a62..83a300b1a 100644 --- a/src/components/documents/inbound-documents-view.tsx +++ b/src/components/documents/inbound-documents-view.tsx @@ -27,6 +27,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { NativeSelect } from "@/components/ui/native-select"; import { Separator } from "@/components/ui/separator"; +import { Skeleton } from "@/components/ui/skeleton"; import { apiDelete, apiFetch, apiPatch, apiPost } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; import { invalidateKeys, queryKeys } from "@/lib/query-keys"; @@ -136,6 +137,7 @@ export function InboundDocumentsView() { type="file" accept="image/jpeg,image/png,image/webp,application/pdf" className="max-w-xs" + aria-label={t("documents.upload.fileLabel")} disabled={upload.isPending} />
-
@@ -549,6 +569,7 @@ function EditFields({ - + + {homeSearched && + !searchHome.isPending && + homeResults.length === 0 && ( +

+ {t("settings.sections.environment.noResults")} +

+ )} {homeResults.length > 0 && (
    {homeResults.map((r) => ( @@ -261,6 +290,7 @@ export function EnvironmentSection() { - + + {travelSearched && + !searchTravel.isPending && + travelResults.length === 0 && ( +

    + {t("settings.sections.environment.noResults")} +

    + )} {travelResults.length > 0 && (
      {travelResults.map((r) => ( @@ -344,6 +392,7 @@ export function EnvironmentSection() { + + ) : null} ) : null} diff --git a/src/lib/query-keys/labs.ts b/src/lib/query-keys/labs.ts index 28e777a3d..6607231d5 100644 --- a/src/lib/query-keys/labs.ts +++ b/src/lib/query-keys/labs.ts @@ -29,6 +29,20 @@ export const labKeys = { params.sortDir, ] as const, + // v1.25 — paginated (offset) reading feed for the biomarker detail page. + // Distinct from `labResultsList` (single-page key) so the infinite-query + // accumulation never collides with a single-page read. Shares the + // `["lab-results", …]` prefix so a result mutation's `labResults()` + // invalidation evicts it too. + labResultsInfinite: (params: { biomarkerId: string; sortDir: string }) => + [ + "lab-results", + "list", + "infinite", + params.biomarkerId, + params.sortDir, + ] as const, + // v1.18.1 — user-scoped Biomarker catalog. The list feeds the picker and // the manager; both invalidate `biomarkers()` after a mutation. biomarkers: () => ["biomarkers"] as const, From d0070646b85bb06faae5ae6b257f1c2479856a3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 18:30:46 +0200 Subject: [PATCH 8/8] test(i18n): guard against English-leak regressions in es/fr/it/pl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing i18n guards prove key-shape parity but pass an English string copied verbatim into another locale — the exact class of bug the v1.25 audit found (illness journal, PHQ-9/GAD-7 screener). Add a heuristic that flags any es/fr/it/pl value byte-identical to en for a multi-word string, with an explicit allowlist for the legitimately-identical keys (clinical abbreviations, units, brands, OAuth labels) and auto-skips for URLs and mask placeholders. --- src/__tests__/i18n-english-leak-guard.test.ts | 197 ++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 src/__tests__/i18n-english-leak-guard.test.ts diff --git a/src/__tests__/i18n-english-leak-guard.test.ts b/src/__tests__/i18n-english-leak-guard.test.ts new file mode 100644 index 000000000..f71f44fa2 --- /dev/null +++ b/src/__tests__/i18n-english-leak-guard.test.ts @@ -0,0 +1,197 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +/** + * v1.25 — English-leak heuristic guard. + * + * The existing i18n guards (`i18n-locale-integrity`, `i18n-drift-guard`, + * `i18n-reverse-coverage`, `i18n-call-site-coverage`) all prove key-SHAPE + * parity and that no key is empty/TODO — but an English string copied verbatim + * into es/fr/it/pl passes every one of them. The v1.25 audit found exactly this + * class of bug (the illness journal shipped English in every locale; the + * PHQ-9/GAD-7 screener shipped English in es/fr/it/pl). + * + * This guard flags any es/fr/it/pl value that is byte-identical to the en value + * for a MULTI-WORD string — the strong signal of a forgotten translation. It is + * deliberately scoped to es/fr/it/pl (de carries many legitimate anglicisms the + * maintainer accepts) and to multi-word values (single tokens like "Note" / + * "Dose" / "BMI" are legitimately identical in several languages). + * + * Two escape hatches keep it lightweight without going silent: + * - a value-pattern rule auto-skips URLs / `mailto:` placeholders, and + * - an explicit KEY allowlist enumerates the legitimately-identical keys + * (clinical abbreviations, units, brand/product names, OAuth field labels). + * A genuinely new leak in any non-allowlisted key fails CI. + */ + +const ROOT = join(__dirname, "../.."); +const MESSAGES_DIR = join(ROOT, "messages"); +const EN_PATH = join(MESSAGES_DIR, "en.json"); + +// The audit scope: the four locales that must never echo English. German is +// intentionally excluded — it carries accepted anglicisms (e.g. "Health +// Score") that would be noise here, and is covered by its own pinned checks in +// `i18n-locale-integrity`. +const LEAK_LOCALES = ["es", "fr", "it", "pl"] as const; + +function flattenValues( + obj: unknown, + prefix: string, + out: [string, string][], +): void { + if (obj == null || typeof obj !== "object") return; + for (const [k, v] of Object.entries(obj as Record)) { + const key = prefix ? `${prefix}.${k}` : k; + if (typeof v === "string") out.push([key, v]); + else if (typeof v === "object") flattenValues(v, key, out); + } +} + +/** + * Letter-initial word tokens, with `{placeholders}` stripped first. A value + * with fewer than two such tokens (a single word, a bare number + unit like + * "30 pts", a pure format string) is not a candidate — only multi-word prose + * is a reliable English-leak signal. + */ +function letterWords(value: string): string[] { + return value.replace(/\{[^}]*\}/g, " ").match(/\p{L}[\p{L}\p{N}]*/gu) ?? []; +} + +// URLs and mailto placeholders are identical across locales by construction. +const URL_LIKE = /(https?:\/\/|mailto:)/i; + +/** + * A mask placeholder (e.g. a UUID template `xxxxxxxx-xxxx-…`) reads as + * "multi-word" only because separators split it; every token is a single + * repeated character. These are locale-independent by construction. + */ +function isMaskPlaceholder(words: string[]): boolean { + return words.length > 0 && words.every((w) => /^(.)\1*$/.test(w)); +} + +/** + * Keys whose value legitimately equals the en value in es/fr/it/pl. Grouped by + * reason. Prune an entry only when the value genuinely diverges in a locale. + */ +const LEGIT_IDENTICAL = new Set([ + // Clinical abbreviations / SI units — identical across locales. + "charts.vo2Max", + "cycle.insights.crosstab.unitGlucose", + "dashboard.metric.unit.glucose", + "dashboard.vo2Max", + "dashboard.vo2MaxShort", + "dashboard.vo2MaxUnit", + "insights.cardioFitness.chartTitle", + "insights.coach.metric.vo2_max", + "insights.derived.composite.READINESS.component.hrv", + "labs.catalog.hs-crp", + "measurements.typeHrvRmssd", + "measurements.typeVo2Max", + "settings.sections.sources.metrics.vo2Max", + // Brand / product / proper nouns. + "admin.glitchtipDsn", + "admin.integrationWebPush", + "admin.offlineGeoEnabled", + "admin.overview.snapshotOfflineGeoOff", + "admin.overview.snapshotOfflineGeoOn", + "admin.webPushVapidTitle", + "dashboard.hero.scoreLabel", + "insights.healthScore.provenance.sources.appleHealth", + "measurements.sourceAppleHealth", + "onboarding.source.more.appleHealth", + "settings.ai.ocrProviderOption.ANTHROPIC", + "settings.ai.providerChain.types.anthropic", + "settings.ai.providerChain.types.codex", + "settings.ai.providerSelect.anthropic", + "settings.moodLogUrl", + "settings.sections.export.import.appleHealth.title", + "settings.sections.sources.sourceLabels.APPLE_HEALTH", + "mood.tag.fastFood", + // Technical / config labels conventionally kept verbatim. + "admin.hostMetrics.diskBusyPercent", + "admin.overview.snapshotBuildSha", + "admin.servicesGlobal", + "admin.webPushVapidPublicKeyPlaceholder", + "settings.ai.baseUrlLabel", + "settings.ai.openai.modelCustomPlaceholder", + "settings.fitbitClientId", + "settings.fitbitClientSecret", + "settings.ouraClientId", + "settings.ouraClientSecret", + "settings.polarClientId", + "settings.polarClientSecret", + "settings.whoopClientId", + "settings.whoopClientSecret", + "settings.sections.api.title", + // Mental-health screener + crisis-resource copy is owned and translated by + // the mental-health workstream (PHQ-9/GAD-7 wording is locale-validated + // separately); the US brand names (988 / Crisis Text Line) stay verbatim. + // Listed here so this guard does not double-own those strings. + "mentalHealth.crisisResource.crisisTextLine.name", + "mentalHealth.crisisResource.euEmotionalSupport.name", + "mentalHealth.crisisResource.findahelpline.name", + "mentalHealth.crisisResource.krisenchat.name", + "mentalHealth.crisisResource.lifeline988.name", + "mentalHealth.crisisResource.nummerGegenKummer.name", + "mentalHealth.crisisResource.telefonSeelsorge.name", + "mentalHealth.items.gad7.1", + "mentalHealth.items.gad7.2", + "mentalHealth.items.gad7.3", + "mentalHealth.items.gad7.4", + "mentalHealth.items.gad7.5", + "mentalHealth.items.gad7.6", + "mentalHealth.items.gad7.7", + "mentalHealth.items.phq9.1", + "mentalHealth.items.phq9.2", + "mentalHealth.items.phq9.3", + "mentalHealth.items.phq9.4", + "mentalHealth.items.phq9.5", + "mentalHealth.items.phq9.6", + "mentalHealth.items.phq9.7", + "mentalHealth.items.phq9.8", + "mentalHealth.items.phq9.9", +]); + +describe("i18n English-leak guard", () => { + const en = JSON.parse(readFileSync(EN_PATH, "utf8")) as Record< + string, + unknown + >; + const enFlat: [string, string][] = []; + flattenValues(en, "", enFlat); + const enMap = new Map(enFlat); + + it.each(LEAK_LOCALES.map((locale) => ({ locale })))( + "$locale does not echo English for multi-word strings", + ({ locale }) => { + const data = JSON.parse( + readFileSync(join(MESSAGES_DIR, `${locale}.json`), "utf8"), + ) as Record; + const flat: [string, string][] = []; + flattenValues(data, "", flat); + + const leaks = flat + .filter(([key, value]) => { + if (LEGIT_IDENTICAL.has(key)) return false; + const enValue = enMap.get(key); + if (enValue === undefined) return false; + if (value.trim() !== enValue.trim()) return false; // translated + if (URL_LIKE.test(value)) return false; + const words = letterWords(value); + if (isMaskPlaceholder(words)) return false; + return words.length >= 2; // multi-word only + }) + .map(([key, value]) => `${key} = ${JSON.stringify(value)}`); + + expect( + leaks, + `${locale}.json carries English values identical to en.json.\n` + + `Translate them, or — if the match is genuinely legitimate (a\n` + + `clinical abbreviation, unit, brand, or proper noun) — add the key\n` + + `to LEGIT_IDENTICAL in this test:\n` + + leaks.map((s) => ` ${s}`).join("\n"), + ).toEqual([]); + }, + ); +});