mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
merge: QA fix polish into release/v1.25.0
This commit is contained in:
commit
3d304acf1e
19 files changed
+566
-180
No files matched your search
+23
-20
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)}
|
||||
>
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in new issue
Block a user