merge: QA fix polish into release/v1.25.0

This commit is contained in:
Marc-André Bombeck committed 2026-06-28 18:36:56 +02:00
commit 3d304acf1e
19 files changed
+566 -180

No files matched your search

+23 -20
View File
@@ -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",
+6 -3
View File
@@ -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",
+23 -20
View File
@@ -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",
+23 -20
View File
@@ -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",
+23 -20
View File
@@ -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",
+23 -20
View File
@@ -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",
@@ -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<string, unknown>)) {
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<string>([
// 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<string, unknown>;
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([]);
},
);
});
@@ -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}
/>
<Button
@@ -163,12 +165,13 @@ export function InboundDocumentsView() {
{/* Document list */}
<section className="flex flex-col gap-2">
{list.isLoading ? (
<div className="flex h-24 items-center justify-center">
<Loader2
className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none"
aria-hidden
/>
</div>
// Skeleton rows (not a centered spinner) so the resolved list lands
// in place without a layout jump — the app's loading convention.
<>
{[0, 1, 2].map((i) => (
<Skeleton key={i} className="h-10 w-full rounded-md" />
))}
</>
) : !list.data || list.data.documents.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t("documents.list.empty")}
@@ -178,10 +181,11 @@ export function InboundDocumentsView() {
<button
key={doc.id}
type="button"
aria-expanded={selectedId === doc.id}
onClick={() =>
setSelectedId(selectedId === doc.id ? null : doc.id)
}
className="hover:bg-accent flex items-center justify-between rounded-md border px-3 py-2 text-left text-sm"
className="hover:bg-accent flex min-h-11 items-center justify-between rounded-md border px-3 py-2 text-left text-sm"
>
<span className="truncate">
{doc.filename ?? t(`documents.kind.${doc.kind}`)}
@@ -256,12 +260,16 @@ function DocumentReview({
if (detail.isLoading) {
return (
<div className="flex h-24 items-center justify-center">
<Loader2
className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none"
aria-hidden
/>
</div>
<Card>
<CardHeader className="flex-row items-center justify-between space-y-0">
<Skeleton className="h-5 w-40" />
<Skeleton className="h-8 w-20" />
</CardHeader>
<CardContent className="flex flex-col gap-3">
<Skeleton className="h-20 w-full rounded-md" />
<Skeleton className="h-20 w-full rounded-md" />
</CardContent>
</Card>
);
}
if (!detail.data) return null;
@@ -365,7 +373,12 @@ function FactCard({
{t(`documents.factType.${fact.factType}`)}
</Badge>
{fact.needsReview ? (
<Badge variant="destructive">
// Calm warning (amber), not an alarming destructive red — a
// low-confidence fact is a "please check", not an error.
<Badge
variant="outline"
className="border-amber-400 text-amber-700 dark:border-amber-900/60 dark:text-amber-400"
>
{t("documents.review.needsReview")}
</Badge>
) : null}
@@ -384,6 +397,7 @@ function FactCard({
<Button
variant={decision === "approve" ? "default" : "outline"}
size="sm"
className="min-h-11 sm:min-h-9"
disabled={fact.needsReview}
onClick={() =>
onDecision(decision === "approve" ? null : "approve")
@@ -394,6 +408,7 @@ function FactCard({
<Button
variant={decision === "reject" ? "destructive" : "outline"}
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => onDecision(decision === "reject" ? null : "reject")}
>
{t("documents.review.reject")}
@@ -402,7 +417,12 @@ function FactCard({
</div>
<div className="mt-2">
<Button variant="ghost" size="xs" onClick={() => setEditing((v) => !v)}>
<Button
variant="ghost"
size="xs"
className="min-h-11 sm:min-h-8"
onClick={() => setEditing((v) => !v)}
>
{editing ? t("documents.review.cancel") : t("documents.review.edit")}
</Button>
</div>
@@ -549,6 +569,7 @@ function EditFields({
</div>
<Button
size="sm"
className="min-h-11 sm:min-h-9"
disabled={pending}
onClick={() => onSubmit(vals, date.trim() === "" ? null : date)}
>
+5 -1
View File
@@ -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,
)}
>
@@ -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}
>
<div className="bg-muted/40 h-4 w-full rounded" />
<div className="bg-muted/40 h-4 w-5/6 rounded" />
<div className="bg-muted/40 h-4 w-2/3 rounded" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-2/3" />
</div>
</section>
);
@@ -70,6 +70,13 @@ export function SleepDebtCard({ debt }: { debt: SleepDebtDto }) {
<CardTitle className="text-base font-semibold">
{t("insights.sleep.debt.title")}
</CardTitle>
{/* 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" ? (
<InfoHint label={t("insights.sleep.debt.computedInfo")} />
) : null}
</div>
</CardHeader>
<CardContent>
@@ -87,17 +87,14 @@ function renderWithCache() {
defaultOptions: { queries: { retry: false } },
});
queryClient.setQueryData(queryKeys.biomarkerDetail(BIOMARKER_ID), MARKER);
// The reading feed is an offset-paginated `useInfiniteQuery` (v1.25) — seed
// the accumulated-pages cache shape under its key.
queryClient.setQueryData(
queryKeys.labResultsList({
queryKeys.labResultsInfinite({
biomarkerId: BIOMARKER_ID,
analyte: undefined,
panel: undefined,
from: undefined,
to: undefined,
page: 0,
sortDir: "desc",
}),
LIST,
{ pages: [LIST], pageParams: [0] },
);
queryClient.setQueryData(
queryKeys.insightsBiomarkerAssessment(BIOMARKER_ID, "en"),
+51 -17
View File
@@ -2,7 +2,12 @@
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { FlaskConical, Pencil, Plus, SlidersHorizontal } from "lucide-react";
import { toast } from "sonner";
@@ -63,6 +68,11 @@ function LabBiomarkerChart(
);
}
// Page size for the offset-paginated reading feed. The server caps a single
// read at 500 (`listLabResultsSchema`); this stays well under that and keeps
// the first paint light, with "Load more" pulling subsequent pages.
const READINGS_PAGE_SIZE = 200;
/**
* v1.18.1 — per-biomarker detail: heading + current-value badge, the proper
* dashboard-style chart with the reference band, and the editable reading
@@ -102,30 +112,40 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
queryFn: () => apiGet<BiomarkerDto>(`/api/biomarkers/${biomarkerId}`),
});
// v1.25 — offset-paginated reading feed. The page no longer fetches a single
// `limit=500` window (which silently truncated a marker once it crossed 500
// readings); it loads a page at a time and a "Load more" control reveals the
// rest. The chart + stat strip render the accumulated set, so loading more
// also extends the trend rather than re-fetching the whole history.
const {
data: list,
isLoading,
isError: listError,
} = useQuery({
queryKey: queryKeys.labResultsList({
biomarkerId,
analyte: undefined,
panel: undefined,
from: undefined,
to: undefined,
page: 0,
sortDir: "desc",
}),
queryFn: () =>
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: queryKeys.labResultsInfinite({ biomarkerId, sortDir: "desc" }),
initialPageParam: 0,
queryFn: ({ pageParam }) =>
apiGet<LabResultListResponse>(
`/api/labs?biomarkerId=${encodeURIComponent(biomarkerId)}&limit=500&sortDir=desc`,
`/api/labs?biomarkerId=${encodeURIComponent(
biomarkerId,
)}&limit=${READINGS_PAGE_SIZE}&offset=${pageParam}&sortDir=desc`,
),
getNextPageParam: (lastPage) => {
const next = lastPage.meta.offset + lastPage.meta.limit;
return next < lastPage.meta.total ? next : undefined;
},
});
const readings: LabResultDto[] = useMemo(() => list?.results ?? [], [list]);
// The reading feed caps at 500 server-side; surface a hint when truncated.
const total = list?.meta?.total ?? 0;
const truncated = total > readings.length;
const readings: LabResultDto[] = useMemo(
() => list?.pages.flatMap((p) => p.results) ?? [],
[list],
);
const total = list?.pages[0]?.meta.total ?? 0;
// More rows exist on the server than are currently loaded.
const truncated = hasNextPage ?? false;
const latest =
readings.length > 0
? [...readings].sort(
@@ -378,6 +398,20 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
<LabHistoryList readings={readings} />
</CardContent>
</Card>
{hasNextPage ? (
<div className="flex justify-center">
<Button
variant="outline"
className="min-h-11 sm:min-h-9"
disabled={isFetchingNextPage}
onClick={() => fetchNextPage()}
>
{isFetchingNextPage
? t("common.loading")
: t("labs.loadMoreReadings")}
</Button>
</div>
) : null}
</div>
) : null}
+62 -13
View File
@@ -88,8 +88,12 @@ export function EnvironmentSection() {
// ── geocoding search (shared by home + travel pickers) ──────────────────
const [homeQuery, setHomeQuery] = useState("");
const [homeResults, setHomeResults] = useState<GeocodeResult[]>([]);
// 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<GeocodeResult[]>([]);
const [travelSearched, setTravelSearched] = useState(false);
const [travelLoc, setTravelLoc] = useState<GeocodeResult | null>(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")}
</p>
)}
<div className="flex gap-2">
<form
className="flex gap-2"
onSubmit={(e) => {
e.preventDefault();
if (homeQuery.trim().length > 0 && !searchHome.isPending) {
searchHome.mutate();
}
}}
>
<Input
value={homeQuery}
onChange={(e) => 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")}
/>
<Button
type="button"
type="submit"
variant="secondary"
disabled={homeQuery.trim().length === 0 || searchHome.isPending}
onClick={() => searchHome.mutate()}
>
{t("settings.sections.environment.search")}
</Button>
</div>
</form>
{homeSearched &&
!searchHome.isPending &&
homeResults.length === 0 && (
<p className="text-muted-foreground text-sm">
{t("settings.sections.environment.noResults")}
</p>
)}
{homeResults.length > 0 && (
<ul className="divide-border divide-y rounded-md border">
{homeResults.map((r) => (
@@ -261,6 +290,7 @@ export function EnvironmentSection() {
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
disabled={setHome.isPending}
onClick={() => setHome.mutate(r)}
>
@@ -296,8 +326,9 @@ export function EnvironmentSection() {
</span>
<Button
type="button"
size="sm"
size="icon"
variant="ghost"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
aria-label={t(
"settings.sections.environment.travel.remove",
)}
@@ -315,24 +346,41 @@ export function EnvironmentSection() {
</p>
)}
<div className="flex gap-2">
<form
className="flex gap-2"
onSubmit={(e) => {
e.preventDefault();
if (travelQuery.trim().length > 0 && !searchTravel.isPending) {
searchTravel.mutate();
}
}}
>
<Input
value={travelQuery}
onChange={(e) => setTravelQuery(e.target.value)}
onChange={(e) => {
setTravelQuery(e.target.value);
setTravelSearched(false);
}}
placeholder={t("settings.sections.environment.searchPlaceholder")}
aria-label={t("settings.sections.environment.searchPlaceholder")}
/>
<Button
type="button"
type="submit"
variant="secondary"
disabled={
travelQuery.trim().length === 0 || searchTravel.isPending
}
onClick={() => searchTravel.mutate()}
>
{t("settings.sections.environment.search")}
</Button>
</div>
</form>
{travelSearched &&
!searchTravel.isPending &&
travelResults.length === 0 && (
<p className="text-muted-foreground text-sm">
{t("settings.sections.environment.noResults")}
</p>
)}
{travelResults.length > 0 && (
<ul className="divide-border divide-y rounded-md border">
{travelResults.map((r) => (
@@ -344,6 +392,7 @@ export function EnvironmentSection() {
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
variant={
travelLoc?.label === r.label ? "default" : "secondary"
}
+5 -1
View File
@@ -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,
)}
>
+15 -9
View File
@@ -230,25 +230,31 @@ export async function fetchAndStoreEnvironment(args: {
}
fetches += 1;
const obsByDate = new Map(observations.map((o) => [o.date, o]));
const wanted = new Set(groupDays);
for (const obs of observations) {
if (!wanted.has(obs.date)) continue;
await upsertObservation(userId, loc, obs);
stored += 1;
// Batch the group's day upserts into a single transaction round-trip
// rather than awaiting one upsert per day in series (a 730-day backfill
// would otherwise issue 730 sequential statements). One transaction per
// location group keeps the per-backfill DB chatter bounded while
// preserving the idempotent upsert semantics.
const ops = observations
.filter((obs) => wanted.has(obs.date))
.map((obs) => buildUpsert(userId, loc, obs));
if (ops.length > 0) {
await prisma.$transaction(ops);
stored += ops.length;
}
// Days the feed did not return (e.g. beyond the settling lag) stay absent.
void obsByDate;
}
return { stored, skipped, fetches };
}
async function upsertObservation(
/** Build the idempotent per-day upsert operation (un-awaited, for a batch). */
function buildUpsert(
userId: string,
loc: ResolvedLocation,
obs: DailyEnvironmentObservation,
): Promise<void> {
) {
const data = {
lat: loc.lat,
lon: loc.lon,
@@ -268,7 +274,7 @@ async function upsertObservation(
weatherCode: obs.weatherCode,
fetchedAt: new Date(),
};
await prisma.environmentContext.upsert({
return prisma.environmentContext.upsert({
where: { userId_date: { userId, date: obs.date } },
create: { userId, date: obs.date, ...data },
update: data,
+32 -9
View File
@@ -20,7 +20,12 @@
import { prisma } from "@/lib/db";
import type { PgBoss } from "pg-boss";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { isModuleEnabled } from "@/lib/modules/gate";
import {
getOperatorModuleAvailability,
isModuleEnabled,
normalisePrefs,
resolveModuleEnabled,
} from "@/lib/modules/gate";
import {
ENVIRONMENT_LOOKBACK_DAYS,
ENVIRONMENT_MAX_BACKFILL_DAYS,
@@ -96,17 +101,35 @@ export async function enqueueEnvironmentFetchDiscovery(
boss: PgBoss,
): Promise<{ enqueued: number; skipped: number }> {
// Candidate set: a home location is required for any non-override day, so an
// account without one cannot produce a row. The per-candidate module check
// then enforces the opt-in flag + operator availability.
const candidates = await prisma.user.findMany({
where: { homeLat: { not: null }, homeLon: { not: null } },
select: { id: true },
});
// account without one cannot produce a row. The opt-in flag + operator
// availability are then resolved in-memory from the candidates' preference
// blobs — one operator-availability read + one `findMany`, instead of an
// `isModuleEnabled` round-trip per candidate (N serial reads on a large
// instance). `environment` is opt-in and non-delegated, so the resolver only
// consults the per-user preference map + the operator availability.
const [candidates, operatorAvailability] = await Promise.all([
prisma.user.findMany({
where: { homeLat: { not: null }, homeLon: { not: null } },
select: { id: true, modulePreferencesJson: true },
}),
getOperatorModuleAvailability(),
]);
let enqueued = 0;
let skipped = 0;
for (const { id } of candidates) {
if (!(await isModuleEnabled(id, "environment"))) {
for (const { id, modulePreferencesJson } of candidates) {
const isEnabled = resolveModuleEnabled(
"environment",
{
gender: null,
disableCoach: false,
modulePreferences: normalisePrefs(modulePreferencesJson),
cycleTrackingEnabled: null,
},
false,
operatorAvailability,
);
if (!isEnabled) {
skipped += 1;
continue;
}
+14
View File
@@ -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,
+13 -2
View File
@@ -9,6 +9,8 @@
*/
import { z } from "zod/v4";
import { isValidTimezone } from "@/lib/tz/format";
/** Strict YYYY-MM-DD calendar day. */
export const dayStringSchema = z
.string()
@@ -24,8 +26,17 @@ export const homeLocationSchema = z
lat: latSchema,
lon: lonSchema,
label: labelSchema,
/** IANA timezone (anchors the stored day-key). */
timezone: z.string().trim().min(1).max(64),
/**
* IANA timezone (anchors the stored day-key). Validated against the
* runtime IANA set, not accepted as a free string — a bogus zone would
* silently mis-key every stored environment day for the account.
*/
timezone: z
.string()
.trim()
.min(1)
.max(64)
.refine(isValidTimezone, "Invalid IANA timezone"),
})
.strict();