diff --git a/messages/de.json b/messages/de.json index 209aa2b8a..a90fecd6a 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4368,7 +4368,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", @@ -7252,7 +7253,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": { @@ -7374,8 +7376,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", @@ -7456,32 +7458,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" }, @@ -7796,7 +7798,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 8deb91e0f..511493b1c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4368,7 +4368,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", @@ -7252,7 +7253,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": { @@ -7796,7 +7798,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 8417fb52f..298329f0c 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4368,7 +4368,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", @@ -7252,7 +7253,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": { @@ -7374,8 +7376,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}", @@ -7456,32 +7458,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" }, @@ -7796,7 +7798,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 34a3da53b..ae54eb4e2 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4368,7 +4368,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", @@ -7252,7 +7253,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": { @@ -7374,8 +7376,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}", @@ -7456,32 +7458,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" }, @@ -7796,7 +7798,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 1bce5300c..7d47c79f9 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4368,7 +4368,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", @@ -7252,7 +7253,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": { @@ -7374,8 +7376,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}", @@ -7456,32 +7458,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" }, @@ -7796,7 +7798,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 916b66fd0..6e0a5ce53 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4368,7 +4368,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", @@ -7252,7 +7253,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": { @@ -7374,8 +7376,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}", @@ -7456,32 +7458,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" }, @@ -7796,7 +7798,8 @@ "label": "Prześlij dokument", "kindLabel": "Typ dokumentu", "button": "Prześlij", - "uploading": "Odczytywanie…" + "uploading": "Odczytywanie…", + "fileLabel": "Plik dokumentu" }, "kind": { "DOCTOR_REPORT": "Raport lekarski", 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([]); + }, + ); +}); 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({ + + ) : null} ) : null} diff --git a/src/components/settings/environment-section.tsx b/src/components/settings/environment-section.tsx index 5f66bc2fb..1a892fd6b 100644 --- a/src/components/settings/environment-section.tsx +++ b/src/components/settings/environment-section.tsx @@ -88,8 +88,12 @@ export function EnvironmentSection() { // ── geocoding search (shared by home + travel pickers) ────────────────── const [homeQuery, setHomeQuery] = useState(""); const [homeResults, setHomeResults] = useState([]); + // Tracks whether a geocode has completed for the current query so a + // zero-result search can surface a "no matches" line instead of silence. + const [homeSearched, setHomeSearched] = useState(false); const [travelQuery, setTravelQuery] = useState(""); const [travelResults, setTravelResults] = useState([]); + const [travelSearched, setTravelSearched] = useState(false); const [travelLoc, setTravelLoc] = useState(null); const [travelStart, setTravelStart] = useState(todayKey()); const [travelEnd, setTravelEnd] = useState(todayKey()); @@ -104,14 +108,20 @@ export function EnvironmentSection() { const searchHome = useMutation({ mutationKey: queryKeys.environmentGeocode("home"), mutationFn: () => geocode(homeQuery), - onSuccess: (data) => setHomeResults(data.results), + onSuccess: (data) => { + setHomeResults(data.results); + setHomeSearched(true); + }, onError: () => toast.error(t("settings.sections.environment.searchError")), }); const searchTravel = useMutation({ mutationKey: queryKeys.environmentGeocode("travel"), mutationFn: () => geocode(travelQuery), - onSuccess: (data) => setTravelResults(data.results), + onSuccess: (data) => { + setTravelResults(data.results); + setTravelSearched(true); + }, onError: () => toast.error(t("settings.sections.environment.searchError")), }); @@ -127,6 +137,7 @@ export function EnvironmentSection() { onSuccess: () => { setHomeResults([]); setHomeQuery(""); + setHomeSearched(false); invalidate(); toast.success(t("settings.sections.environment.homeSaved")); }, @@ -147,6 +158,7 @@ export function EnvironmentSection() { setTravelLoc(null); setTravelResults([]); setTravelQuery(""); + setTravelSearched(false); invalidate(); toast.success(t("settings.sections.environment.travelAdded")); }, @@ -234,22 +246,39 @@ export function EnvironmentSection() { {t("settings.sections.environment.home.none")}

)} -
+
{ + e.preventDefault(); + if (homeQuery.trim().length > 0 && !searchHome.isPending) { + searchHome.mutate(); + } + }} + > setHomeQuery(e.target.value)} + onChange={(e) => { + setHomeQuery(e.target.value); + setHomeSearched(false); + }} placeholder={t("settings.sections.environment.searchPlaceholder")} aria-label={t("settings.sections.environment.searchPlaceholder")} /> -
+ + {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() {