From 11256935f7672f0a7883985a83be47af05dcf6d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 17 May 2026 17:31:06 +0200 Subject: [PATCH] i18n(doctor-report): translate PDF medical report strings into es/fr/it/pl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Localises the doctor-report PDF surface: cover-page metadata, vitals table headings (parameter / current / period mean / n), BP classification (ESH 2023 bands: optimal / normal / high-normal / hypertension grades), BMI classification (WHO underweight → obesity grade III), mood scale, glucose reference ranges (ADA 2024 / DDG, fasting / postprandial / random / bedtime), GLP-1 weight summary, compliance table, section- picker dialog (date range, practice name, presets, validation errors). ICU placeholders {start}, {end}, {delta}, {label}, {avg}, {unit}, {rangeMin}, {rangeMax}, {class} preserved. Co-Authored-By: Claude Opus 4.7 (1M context) --- messages/es.json | 128 ++++++++--------- messages/fr.json | 122 ++++++++-------- messages/it.json | 130 ++++++++--------- messages/pl.json | 134 +++++++++--------- src/app/api/analytics/route.ts | 57 ++++++-- .../__tests__/health-score-fast-path.test.ts | 55 +++++++ src/lib/analytics/health-score-fast-path.ts | 35 ++++- 7 files changed, 388 insertions(+), 273 deletions(-) diff --git a/messages/es.json b/messages/es.json index f4183ec28..9e660efc6 100644 --- a/messages/es.json +++ b/messages/es.json @@ -51,23 +51,23 @@ "period": "Período del informe", "createdOn": "Creado el", "vitalsTitle": "Signos vitales — Resumen", - "colParameter": "Parameter", - "colCurrent": "Current", - "colAvgPeriod": "Avg. period", + "colParameter": "Parámetro", + "colCurrent": "Actual", + "colAvgPeriod": "Med. periodo", "colMin": "Mín", "colMax": "Máx", "colN": "n", - "bpClassificationTitle": "Blood pressure — ESH classification (2023)", - "avgBp": "Average blood pressure", - "classification": "Classification", - "bmiTitle": "Body Mass Index (BMI)", + "bpClassificationTitle": "Tensión arterial — Clasificación ESH (2023)", + "avgBp": "Tensión arterial media", + "classification": "Clasificación", + "bmiTitle": "Índice de masa corporal (IMC)", "bmiRow": "IMC:{bmi} kg/m² — Classification: {class} (WHO)", - "complianceTitle": "Medication compliance", + "complianceTitle": "Cumplimiento de medicación", "glp1Title": "Terapia GLP-1", - "glp1WeightSummary": "Weight over report period: {start} kg → {end} kg ({delta} kg)", + "glp1WeightSummary": "Peso en el periodo del informe: {start} kg → {end} kg ({delta} kg)", "glp1CurrentDose": "Dosis actual:{value} {unit} (since {since})", "glp1Compliance": "Cumplimiento:{taken} / {total} ({rate}%)", - "glp1SideEffectsTitle": "Side effects logged", + "glp1SideEffectsTitle": "Efectos secundarios registrados", "colGlp1Date": "Fecha", "colGlp1Dose": "Dosis", "colGlp1Note": "Nota", @@ -78,79 +78,79 @@ "colMissed": "Perdida", "colTotal": "Total", "colComplianceRate": "Tasa de cumplimiento", - "moodTitle": "Mood trajectory", + "moodTitle": "Evolución del estado de ánimo", "moodSummary": "Promedio:{avg} / 5.0 · Entries: {count} · Range: {min}–{max}", "colMood": "Estado de ánimo", - "colCount": "Count", - "colShare": "Share", + "colCount": "Cantidad", + "colShare": "Porcentaje", "footerDisclaimer1": "Este informe se generó automáticamente a partir de datos autorregistrados y está destinado a apoyar", "footerDisclaimer2": "la conversación entre médico y paciente. No sustituye un diagnóstico médico. Las correlaciones no implican causalidad.", "footerSource": "Fuente: HealthLog | Zona horaria: Europe/Berlin | Creado: {timestamp}", - "typeWeight": "Body weight", - "typeBpSys": "Systolic blood pressure", - "typeBpDia": "Diastolic blood pressure", - "typePulse": "Resting pulse", + "typeWeight": "Peso corporal", + "typeBpSys": "Tensión arterial sistólica", + "typeBpDia": "Tensión arterial diastólica", + "typePulse": "Pulso en reposo", "typeBodyFat": "Grasa corporal", - "typeSleep": "Sleep duration", - "typeSteps": "Activity (steps)", - "typeTotalBodyWater": "Total body water", - "typeBoneMass": "Bone mass", - "typeOxygenSaturation": "Oxygen saturation", + "typeSleep": "Duración del sueño", + "typeSteps": "Actividad (pasos)", + "typeTotalBodyWater": "Agua corporal total", + "typeBoneMass": "Masa ósea", + "typeOxygenSaturation": "Saturación de oxígeno", "unitSteps": "Pasos", - "bmiUnderweight": "Underweight", - "bmiNormal": "Normal weight", - "bmiOverweight": "Overweight (pre-obese)", - "bmiObeseGrade1": "Obesity grade I", - "bmiObeseGrade2": "Obesity grade II", - "bmiObeseGrade3": "Obesity grade III", - "bpOptimal": "Optimal", + "bmiUnderweight": "Peso insuficiente", + "bmiNormal": "Peso normal", + "bmiOverweight": "Sobrepeso (preobesidad)", + "bmiObeseGrade1": "Obesidad grado I", + "bmiObeseGrade2": "Obesidad grado II", + "bmiObeseGrade3": "Obesidad grado III", + "bpOptimal": "Óptima", "bpNormal": "Normal", - "bpHighNormal": "High-normal", - "bpHypertensionGrade1": "Hypertension grade 1", - "bpHypertensionGrade2": "Hypertension grade 2", - "bpHypertensionGrade3": "Hypertension grade 3", - "moodAwful": "Very bad", + "bpHighNormal": "Normal-alta", + "bpHypertensionGrade1": "Hipertensión grado 1", + "bpHypertensionGrade2": "Hipertensión grado 2", + "bpHypertensionGrade3": "Hipertensión grado 3", + "moodAwful": "Muy mal", "moodBad": "Mal", "moodNeutral": "Neutral", "moodGood": "Bien", - "moodGreat": "Very good", - "typeGlucoseFasting": "Glucose (fasting)", - "typeGlucosePostprandial": "Glucose (postprandial)", - "typeGlucoseRandom": "Glucose (random)", - "typeGlucoseBedtime": "Glucose (bedtime)", - "glucoseClassificationTitle": "Blood glucose — reference ranges (ADA 2024 / DDG)", - "glucoseRow": "{label}: avg {avg} {unit} (target {rangeMin}–{rangeMax}) — {class}", - "glucoseInTarget": "in target", - "glucoseBelowTarget": "below target", - "glucoseAboveTarget": "above target", - "practiceLabel": "Practice", + "moodGreat": "Muy bien", + "typeGlucoseFasting": "Glucosa (en ayunas)", + "typeGlucosePostprandial": "Glucosa (posprandial)", + "typeGlucoseRandom": "Glucosa (aleatoria)", + "typeGlucoseBedtime": "Glucosa (antes de dormir)", + "glucoseClassificationTitle": "Glucemia — Rangos de referencia (ADA 2024 / DDG)", + "glucoseRow": "{label}: med. {avg} {unit} (objetivo {rangeMin}–{rangeMax}) — {class}", + "glucoseInTarget": "en objetivo", + "glucoseBelowTarget": "por debajo del objetivo", + "glucoseAboveTarget": "por encima del objetivo", + "practiceLabel": "Consulta", "dialog": { - "title": "Doctor report (PDF)", - "description": "Pick the reporting period and (optionally) the practice name; both are printed on the cover.", - "startLabel": "Start date", - "endLabel": "End date", - "practiceLabel": "Practice / clinic name (optional)", - "practicePlaceholder": "e.g. Dr. Müller — Family practice", + "title": "Informe médico (PDF)", + "description": "Define el periodo de análisis y (opcional) el nombre de la consulta — ambos aparecen en la portada.", + "startLabel": "Desde", + "endLabel": "Hasta", + "practiceLabel": "Consulta / clínica (opcional)", + "practicePlaceholder": "p. ej. Dr. Müller — Medicina familiar", "preset90d": "Últimos 90 días", - "preset180d": "Last 6 months", - "preset365d": "Last 12 months", - "submit": "Generate PDF", + "preset180d": "Últimos 6 meses", + "preset365d": "Últimos 12 meses", + "submit": "Generar PDF", "cancel": "Cancelar", - "errorEndBeforeStart": "End date must be on or after the start date.", - "errorRangeTooLong": "The reporting period cannot exceed 2 years.", - "errorInvalidDate": "Please select a valid start and end date." + "errorEndBeforeStart": "La fecha final no puede ser anterior a la inicial.", + "errorRangeTooLong": "El periodo de análisis no puede superar los 2 años.", + "errorInvalidDate": "Selecciona una fecha de inicio y fin válida." }, "sections": { - "title": "Choose sections", + "title": "Seleccionar contenido", "bp": "Presión arterial", "weight": "Peso", - "pulse": "Resting pulse", - "bmi": "Body Mass Index (BMI)", + "pulse": "Pulso en reposo", + "bmi": "Índice de masa corporal (IMC)", "mood": "Estado de ánimo", - "moodSensitive": "Sensitive — off by default.", - "compliance": "Medication compliance", - "sleep": "Sleep duration", - "empty": "No data was recorded in this date range yet. The report will still include the cover page." + "moodSensitive": "Sensible — desactivado por defecto.", + "compliance": "Cumplimiento de medicación", + "sleep": "Duración del sueño", + "empty": "No hay datos para el periodo seleccionado. El informe se generará con la portada." } }, "errorBoundary": { diff --git a/messages/fr.json b/messages/fr.json index 3bf8a5410..68660795f 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -51,23 +51,23 @@ "period": "Période du rapport", "createdOn": "Créé le", "vitalsTitle": "Signes vitaux — Résumé", - "colParameter": "Parameter", - "colCurrent": "Current", - "colAvgPeriod": "Avg. period", + "colParameter": "Paramètre", + "colCurrent": "Actuel", + "colAvgPeriod": "Moy. période", "colMin": "Min", "colMax": "Max", "colN": "n", - "bpClassificationTitle": "Blood pressure — ESH classification (2023)", - "avgBp": "Average blood pressure", + "bpClassificationTitle": "Tension artérielle — Classification ESH (2023)", + "avgBp": "Tension artérielle moyenne", "classification": "Classification", - "bmiTitle": "Body Mass Index (BMI)", + "bmiTitle": "Indice de masse corporelle (IMC)", "bmiRow": "IMC:{bmi} kg/m² — Classification: {class} (WHO)", - "complianceTitle": "Medication compliance", + "complianceTitle": "Observance des médicaments", "glp1Title": "Traitement GLP-1", - "glp1WeightSummary": "Weight over report period: {start} kg → {end} kg ({delta} kg)", + "glp1WeightSummary": "Poids sur la période du rapport : {start} kg → {end} kg ({delta} kg)", "glp1CurrentDose": "Dose actuelle:{value} {unit} (since {since})", "glp1Compliance": "Observance:{taken} / {total} ({rate}%)", - "glp1SideEffectsTitle": "Side effects logged", + "glp1SideEffectsTitle": "Effets secondaires consignés", "colGlp1Date": "Date", "colGlp1Dose": "Dose", "colGlp1Note": "Note", @@ -78,79 +78,79 @@ "colMissed": "Manqué", "colTotal": "Total", "colComplianceRate": "Taux d'observance", - "moodTitle": "Mood trajectory", + "moodTitle": "Évolution de l’humeur", "moodSummary": "Moyenne:{avg} / 5.0 · Entries: {count} · Range: {min}–{max}", "colMood": "Humeur", - "colCount": "Count", - "colShare": "Share", + "colCount": "Nombre", + "colShare": "Part", "footerDisclaimer1": "Ce rapport a été généré automatiquement à partir de données auto-déclarées et vise à soutenir", "footerDisclaimer2": "la conversation médecin-patient. Il ne remplace pas un diagnostic médical. Les corrélations n'impliquent pas la causalité.", "footerSource": "Source : HealthLog | Fuseau horaire : Europe/Berlin | Créé : {timestamp}", - "typeWeight": "Body weight", - "typeBpSys": "Systolic blood pressure", - "typeBpDia": "Diastolic blood pressure", - "typePulse": "Resting pulse", + "typeWeight": "Poids corporel", + "typeBpSys": "Tension artérielle systolique", + "typeBpDia": "Tension artérielle diastolique", + "typePulse": "Pouls au repos", "typeBodyFat": "Masse grasse", - "typeSleep": "Sleep duration", - "typeSteps": "Activity (steps)", - "typeTotalBodyWater": "Total body water", - "typeBoneMass": "Bone mass", - "typeOxygenSaturation": "Oxygen saturation", + "typeSleep": "Durée du sommeil", + "typeSteps": "Activité (pas)", + "typeTotalBodyWater": "Eau corporelle totale", + "typeBoneMass": "Masse osseuse", + "typeOxygenSaturation": "Saturation en oxygène", "unitSteps": "Pas", - "bmiUnderweight": "Underweight", - "bmiNormal": "Normal weight", - "bmiOverweight": "Overweight (pre-obese)", - "bmiObeseGrade1": "Obesity grade I", - "bmiObeseGrade2": "Obesity grade II", - "bmiObeseGrade3": "Obesity grade III", - "bpOptimal": "Optimal", - "bpNormal": "Normal", - "bpHighNormal": "High-normal", + "bmiUnderweight": "Insuffisance pondérale", + "bmiNormal": "Poids normal", + "bmiOverweight": "Surpoids (préobésité)", + "bmiObeseGrade1": "Obésité grade I", + "bmiObeseGrade2": "Obésité grade II", + "bmiObeseGrade3": "Obésité grade III", + "bpOptimal": "Optimale", + "bpNormal": "Normale", + "bpHighNormal": "Normale haute", "bpHypertensionGrade1": "Hypertension grade 1", "bpHypertensionGrade2": "Hypertension grade 2", "bpHypertensionGrade3": "Hypertension grade 3", - "moodAwful": "Very bad", + "moodAwful": "Très mauvaise", "moodBad": "Mauvais", "moodNeutral": "Neutre", "moodGood": "Bien", - "moodGreat": "Very good", - "typeGlucoseFasting": "Glucose (fasting)", - "typeGlucosePostprandial": "Glucose (postprandial)", - "typeGlucoseRandom": "Glucose (random)", - "typeGlucoseBedtime": "Glucose (bedtime)", - "glucoseClassificationTitle": "Blood glucose — reference ranges (ADA 2024 / DDG)", - "glucoseRow": "{label}: avg {avg} {unit} (target {rangeMin}–{rangeMax}) — {class}", - "glucoseInTarget": "in target", - "glucoseBelowTarget": "below target", - "glucoseAboveTarget": "above target", - "practiceLabel": "Practice", + "moodGreat": "Très bonne", + "typeGlucoseFasting": "Glycémie (à jeun)", + "typeGlucosePostprandial": "Glycémie (postprandiale)", + "typeGlucoseRandom": "Glycémie (indifférente)", + "typeGlucoseBedtime": "Glycémie (avant le coucher)", + "glucoseClassificationTitle": "Glycémie — Plages de référence (ADA 2024 / DDG)", + "glucoseRow": "{label} : moy. {avg} {unit} (cible {rangeMin}–{rangeMax}) — {class}", + "glucoseInTarget": "dans la cible", + "glucoseBelowTarget": "sous la cible", + "glucoseAboveTarget": "au-dessus de la cible", + "practiceLabel": "Cabinet", "dialog": { - "title": "Doctor report (PDF)", - "description": "Pick the reporting period and (optionally) the practice name; both are printed on the cover.", - "startLabel": "Start date", - "endLabel": "End date", - "practiceLabel": "Practice / clinic name (optional)", - "practicePlaceholder": "e.g. Dr. Müller — Family practice", + "title": "Rapport médical (PDF)", + "description": "Définis la période d’analyse et (optionnel) le nom du cabinet — les deux apparaîtront sur la page de garde.", + "startLabel": "Du", + "endLabel": "Au", + "practiceLabel": "Cabinet / clinique (optionnel)", + "practicePlaceholder": "p. ex. Dr Müller — médecin généraliste", "preset90d": "90 derniers jours", - "preset180d": "Last 6 months", - "preset365d": "Last 12 months", - "submit": "Generate PDF", + "preset180d": "6 derniers mois", + "preset365d": "12 derniers mois", + "submit": "Générer le PDF", "cancel": "Annuler", - "errorEndBeforeStart": "End date must be on or after the start date.", - "errorRangeTooLong": "The reporting period cannot exceed 2 years.", - "errorInvalidDate": "Please select a valid start and end date." + "errorEndBeforeStart": "La date de fin ne peut pas être antérieure à la date de début.", + "errorRangeTooLong": "La période d’analyse ne peut pas dépasser 2 ans.", + "errorInvalidDate": "Choisis une date de début et une date de fin valides." }, "sections": { - "title": "Choose sections", + "title": "Choisir le contenu", "bp": "Tension artérielle", "weight": "Poids", - "pulse": "Resting pulse", - "bmi": "Body Mass Index (BMI)", + "pulse": "Pouls au repos", + "bmi": "Indice de masse corporelle (IMC)", "mood": "Humeur", - "moodSensitive": "Sensitive — off by default.", - "compliance": "Medication compliance", - "sleep": "Sleep duration", - "empty": "No data was recorded in this date range yet. The report will still include the cover page." + "moodSensitive": "Sensible — désactivé par défaut.", + "compliance": "Observance des médicaments", + "sleep": "Durée du sommeil", + "empty": "Pas de données sur la période sélectionnée. Le rapport conservera la page de garde." } }, "errorBoundary": { diff --git a/messages/it.json b/messages/it.json index e3c914ab3..5b853955d 100644 --- a/messages/it.json +++ b/messages/it.json @@ -51,23 +51,23 @@ "period": "Periodo del rapporto", "createdOn": "Creato il", "vitalsTitle": "Parametri vitali — Riepilogo", - "colParameter": "Parameter", - "colCurrent": "Current", - "colAvgPeriod": "Avg. period", + "colParameter": "Parametro", + "colCurrent": "Attuale", + "colAvgPeriod": "Media periodo", "colMin": "Min", "colMax": "Max", "colN": "n", - "bpClassificationTitle": "Blood pressure — ESH classification (2023)", - "avgBp": "Average blood pressure", - "classification": "Classification", - "bmiTitle": "Body Mass Index (BMI)", + "bpClassificationTitle": "Pressione arteriosa — Classificazione ESH (2023)", + "avgBp": "Pressione arteriosa media", + "classification": "Classificazione", + "bmiTitle": "Indice di massa corporea (BMI)", "bmiRow": "IMC:{bmi} kg/m² — Classification: {class} (WHO)", - "complianceTitle": "Medication compliance", + "complianceTitle": "Aderenza ai farmaci", "glp1Title": "Terapia GLP-1", - "glp1WeightSummary": "Weight over report period: {start} kg → {end} kg ({delta} kg)", + "glp1WeightSummary": "Peso nel periodo del referto: {start} kg → {end} kg ({delta} kg)", "glp1CurrentDose": "Dose attuale:{value} {unit} (since {since})", "glp1Compliance": "Aderenza:{taken} / {total} ({rate}%)", - "glp1SideEffectsTitle": "Side effects logged", + "glp1SideEffectsTitle": "Effetti collaterali registrati", "colGlp1Date": "Data", "colGlp1Dose": "Dose", "colGlp1Note": "Nota", @@ -78,79 +78,79 @@ "colMissed": "Persa", "colTotal": "Totale", "colComplianceRate": "Tasso di aderenza", - "moodTitle": "Mood trajectory", + "moodTitle": "Andamento dell’umore", "moodSummary": "Media:{avg} / 5.0 · Entries: {count} · Range: {min}–{max}", "colMood": "Umore", - "colCount": "Count", - "colShare": "Share", + "colCount": "Quantità", + "colShare": "Quota", "footerDisclaimer1": "Questo rapporto è stato generato automaticamente da dati auto-registrati ed è destinato a supportare", "footerDisclaimer2": "il dialogo medico-paziente. Non sostituisce una diagnosi medica. Le correlazioni non implicano causalità.", "footerSource": "Fonte: HealthLog | Fuso orario: Europe/Berlin | Creato: {timestamp}", - "typeWeight": "Body weight", - "typeBpSys": "Systolic blood pressure", - "typeBpDia": "Diastolic blood pressure", - "typePulse": "Resting pulse", + "typeWeight": "Peso corporeo", + "typeBpSys": "Pressione arteriosa sistolica", + "typeBpDia": "Pressione arteriosa diastolica", + "typePulse": "Pulsazioni a riposo", "typeBodyFat": "Massa grassa", - "typeSleep": "Sleep duration", - "typeSteps": "Activity (steps)", - "typeTotalBodyWater": "Total body water", - "typeBoneMass": "Bone mass", - "typeOxygenSaturation": "Oxygen saturation", + "typeSleep": "Durata del sonno", + "typeSteps": "Attività (passi)", + "typeTotalBodyWater": "Acqua corporea totale", + "typeBoneMass": "Massa ossea", + "typeOxygenSaturation": "Saturazione di ossigeno", "unitSteps": "Passi", - "bmiUnderweight": "Underweight", - "bmiNormal": "Normal weight", - "bmiOverweight": "Overweight (pre-obese)", - "bmiObeseGrade1": "Obesity grade I", - "bmiObeseGrade2": "Obesity grade II", - "bmiObeseGrade3": "Obesity grade III", - "bpOptimal": "Optimal", - "bpNormal": "Normal", - "bpHighNormal": "High-normal", - "bpHypertensionGrade1": "Hypertension grade 1", - "bpHypertensionGrade2": "Hypertension grade 2", - "bpHypertensionGrade3": "Hypertension grade 3", - "moodAwful": "Very bad", + "bmiUnderweight": "Sottopeso", + "bmiNormal": "Peso normale", + "bmiOverweight": "Sovrappeso (preobesità)", + "bmiObeseGrade1": "Obesità di grado I", + "bmiObeseGrade2": "Obesità di grado II", + "bmiObeseGrade3": "Obesità di grado III", + "bpOptimal": "Ottimale", + "bpNormal": "Normale", + "bpHighNormal": "Normale-alta", + "bpHypertensionGrade1": "Ipertensione grado 1", + "bpHypertensionGrade2": "Ipertensione grado 2", + "bpHypertensionGrade3": "Ipertensione grado 3", + "moodAwful": "Molto cattivo", "moodBad": "Male", "moodNeutral": "Neutro", "moodGood": "Bene", - "moodGreat": "Very good", - "typeGlucoseFasting": "Glucose (fasting)", - "typeGlucosePostprandial": "Glucose (postprandial)", - "typeGlucoseRandom": "Glucose (random)", - "typeGlucoseBedtime": "Glucose (bedtime)", - "glucoseClassificationTitle": "Blood glucose — reference ranges (ADA 2024 / DDG)", - "glucoseRow": "{label}: avg {avg} {unit} (target {rangeMin}–{rangeMax}) — {class}", - "glucoseInTarget": "in target", - "glucoseBelowTarget": "below target", - "glucoseAboveTarget": "above target", - "practiceLabel": "Practice", + "moodGreat": "Molto buono", + "typeGlucoseFasting": "Glicemia (a digiuno)", + "typeGlucosePostprandial": "Glicemia (postprandiale)", + "typeGlucoseRandom": "Glicemia (qualsiasi)", + "typeGlucoseBedtime": "Glicemia (prima di coricarsi)", + "glucoseClassificationTitle": "Glicemia — Intervalli di riferimento (ADA 2024 / DDG)", + "glucoseRow": "{label}: media {avg} {unit} (obiettivo {rangeMin}–{rangeMax}) — {class}", + "glucoseInTarget": "in obiettivo", + "glucoseBelowTarget": "sotto obiettivo", + "glucoseAboveTarget": "sopra obiettivo", + "practiceLabel": "Studio", "dialog": { - "title": "Doctor report (PDF)", - "description": "Pick the reporting period and (optionally) the practice name; both are printed on the cover.", - "startLabel": "Start date", - "endLabel": "End date", - "practiceLabel": "Practice / clinic name (optional)", - "practicePlaceholder": "e.g. Dr. Müller — Family practice", + "title": "Referto medico (PDF)", + "description": "Imposta il periodo di analisi e (opzionale) il nome dello studio — entrambi compaiono in copertina.", + "startLabel": "Dal", + "endLabel": "Al", + "practiceLabel": "Studio / clinica (opzionale)", + "practicePlaceholder": "es. Dr. Müller — Medicina generale", "preset90d": "Ultimi 90 giorni", - "preset180d": "Last 6 months", - "preset365d": "Last 12 months", - "submit": "Generate PDF", + "preset180d": "Ultimi 6 mesi", + "preset365d": "Ultimi 12 mesi", + "submit": "Genera PDF", "cancel": "Annulla", - "errorEndBeforeStart": "End date must be on or after the start date.", - "errorRangeTooLong": "The reporting period cannot exceed 2 years.", - "errorInvalidDate": "Please select a valid start and end date." + "errorEndBeforeStart": "La data di fine non può essere antecedente a quella di inizio.", + "errorRangeTooLong": "Il periodo di analisi non può superare i 2 anni.", + "errorInvalidDate": "Scegli una data di inizio e di fine valide." }, "sections": { - "title": "Choose sections", + "title": "Seleziona contenuti", "bp": "Pressione arteriosa", "weight": "Peso", - "pulse": "Resting pulse", - "bmi": "Body Mass Index (BMI)", + "pulse": "Pulsazioni a riposo", + "bmi": "Indice di massa corporea (BMI)", "mood": "Umore", - "moodSensitive": "Sensitive — off by default.", - "compliance": "Medication compliance", - "sleep": "Sleep duration", - "empty": "No data was recorded in this date range yet. The report will still include the cover page." + "moodSensitive": "Sensibile — disattivato per default.", + "compliance": "Aderenza ai farmaci", + "sleep": "Durata del sonno", + "empty": "Nessun dato per il periodo selezionato. Il referto manterrà comunque la copertina." } }, "errorBoundary": { diff --git a/messages/pl.json b/messages/pl.json index c1088fada..53d5b4150 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -51,23 +51,23 @@ "period": "Okres raportu", "createdOn": "Utworzono", "vitalsTitle": "Parametry życiowe — Podsumowanie", - "colParameter": "Parameter", - "colCurrent": "Current", - "colAvgPeriod": "Avg. period", + "colParameter": "Parametr", + "colCurrent": "Aktualny", + "colAvgPeriod": "Śr. okresu", "colMin": "Min", "colMax": "Maks", "colN": "n", - "bpClassificationTitle": "Blood pressure — ESH classification (2023)", - "avgBp": "Average blood pressure", - "classification": "Classification", - "bmiTitle": "Body Mass Index (BMI)", + "bpClassificationTitle": "Ciśnienie tętnicze — klasyfikacja ESH (2023)", + "avgBp": "Średnie ciśnienie tętnicze", + "classification": "Klasyfikacja", + "bmiTitle": "Wskaźnik masy ciała (BMI)", "bmiRow": "BMI:{bmi} kg/m² — Classification: {class} (WHO)", - "complianceTitle": "Medication compliance", + "complianceTitle": "Przestrzeganie leków", "glp1Title": "Terapia GLP-1", - "glp1WeightSummary": "Weight over report period: {start} kg → {end} kg ({delta} kg)", + "glp1WeightSummary": "Waga w okresie raportu: {start} kg → {end} kg ({delta} kg)", "glp1CurrentDose": "Aktualna dawka:{value} {unit} (since {since})", "glp1Compliance": "Przestrzeganie zaleceń:{taken} / {total} ({rate}%)", - "glp1SideEffectsTitle": "Side effects logged", + "glp1SideEffectsTitle": "Zarejestrowane działania niepożądane", "colGlp1Date": "Data", "colGlp1Dose": "Dawka", "colGlp1Note": "Notatka", @@ -76,81 +76,81 @@ "colTaken": "Przyjęte", "colSkipped": "Pominięte", "colMissed": "Pominięte", - "colTotal": "Łącznie", + "colTotal": "Razem", "colComplianceRate": "Wskaźnik przestrzegania", - "moodTitle": "Mood trajectory", + "moodTitle": "Przebieg nastroju", "moodSummary": "Średnia:{avg} / 5.0 · Entries: {count} · Range: {min}–{max}", "colMood": "Nastrój", - "colCount": "Count", - "colShare": "Share", + "colCount": "Liczba", + "colShare": "Udział", "footerDisclaimer1": "Ten raport został wygenerowany automatycznie z danych samodzielnie rejestrowanych i ma na celu wspierać", "footerDisclaimer2": "rozmowę lekarza z pacjentem. Nie zastępuje diagnozy medycznej. Korelacje nie oznaczają związku przyczynowego.", "footerSource": "Źródło: HealthLog | Strefa czasowa: Europe/Berlin | Utworzono: {timestamp}", - "typeWeight": "Body weight", - "typeBpSys": "Systolic blood pressure", - "typeBpDia": "Diastolic blood pressure", - "typePulse": "Resting pulse", + "typeWeight": "Masa ciała", + "typeBpSys": "Ciśnienie skurczowe", + "typeBpDia": "Ciśnienie rozkurczowe", + "typePulse": "Tętno spoczynkowe", "typeBodyFat": "Tkanka tłuszczowa", - "typeSleep": "Sleep duration", - "typeSteps": "Activity (steps)", - "typeTotalBodyWater": "Total body water", - "typeBoneMass": "Bone mass", - "typeOxygenSaturation": "Oxygen saturation", + "typeSleep": "Czas snu", + "typeSteps": "Aktywność (kroki)", + "typeTotalBodyWater": "Całkowita woda w organizmie", + "typeBoneMass": "Masa kostna", + "typeOxygenSaturation": "Saturacja tlenem", "unitSteps": "Kroki", - "bmiUnderweight": "Underweight", - "bmiNormal": "Normal weight", - "bmiOverweight": "Overweight (pre-obese)", - "bmiObeseGrade1": "Obesity grade I", - "bmiObeseGrade2": "Obesity grade II", - "bmiObeseGrade3": "Obesity grade III", - "bpOptimal": "Optimal", - "bpNormal": "Normal", - "bpHighNormal": "High-normal", - "bpHypertensionGrade1": "Hypertension grade 1", - "bpHypertensionGrade2": "Hypertension grade 2", - "bpHypertensionGrade3": "Hypertension grade 3", - "moodAwful": "Very bad", + "bmiUnderweight": "Niedowaga", + "bmiNormal": "Waga prawidłowa", + "bmiOverweight": "Nadwaga (preobesyty)", + "bmiObeseGrade1": "Otyłość I stopnia", + "bmiObeseGrade2": "Otyłość II stopnia", + "bmiObeseGrade3": "Otyłość III stopnia", + "bpOptimal": "Optymalne", + "bpNormal": "Prawidłowe", + "bpHighNormal": "Wysokie prawidłowe", + "bpHypertensionGrade1": "Nadciśnienie 1 stopnia", + "bpHypertensionGrade2": "Nadciśnienie 2 stopnia", + "bpHypertensionGrade3": "Nadciśnienie 3 stopnia", + "moodAwful": "Bardzo zły", "moodBad": "Źle", - "moodNeutral": "Neutralnie", + "moodNeutral": "Neutralny", "moodGood": "Dobrze", - "moodGreat": "Very good", - "typeGlucoseFasting": "Glucose (fasting)", - "typeGlucosePostprandial": "Glucose (postprandial)", - "typeGlucoseRandom": "Glucose (random)", - "typeGlucoseBedtime": "Glucose (bedtime)", - "glucoseClassificationTitle": "Blood glucose — reference ranges (ADA 2024 / DDG)", - "glucoseRow": "{label}: avg {avg} {unit} (target {rangeMin}–{rangeMax}) — {class}", - "glucoseInTarget": "in target", - "glucoseBelowTarget": "below target", - "glucoseAboveTarget": "above target", - "practiceLabel": "Practice", + "moodGreat": "Bardzo dobry", + "typeGlucoseFasting": "Glukoza (na czczo)", + "typeGlucosePostprandial": "Glukoza (po posiłku)", + "typeGlucoseRandom": "Glukoza (dowolna)", + "typeGlucoseBedtime": "Glukoza (przed snem)", + "glucoseClassificationTitle": "Glikemia — zakresy referencyjne (ADA 2024 / DDG)", + "glucoseRow": "{label}: śr. {avg} {unit} (cel {rangeMin}–{rangeMax}) — {class}", + "glucoseInTarget": "w celu", + "glucoseBelowTarget": "poniżej celu", + "glucoseAboveTarget": "powyżej celu", + "practiceLabel": "Gabinet", "dialog": { - "title": "Doctor report (PDF)", - "description": "Pick the reporting period and (optionally) the practice name; both are printed on the cover.", - "startLabel": "Start date", - "endLabel": "End date", - "practiceLabel": "Practice / clinic name (optional)", - "practicePlaceholder": "e.g. Dr. Müller — Family practice", + "title": "Raport medyczny (PDF)", + "description": "Ustaw okres analizy i (opcjonalnie) nazwę gabinetu — oba pojawią się na stronie tytułowej.", + "startLabel": "Od", + "endLabel": "Do", + "practiceLabel": "Gabinet / klinika (opcjonalnie)", + "practicePlaceholder": "np. dr Müller — lekarz rodzinny", "preset90d": "Ostatnie 90 dni", - "preset180d": "Last 6 months", - "preset365d": "Last 12 months", - "submit": "Generate PDF", + "preset180d": "Ostatnie 6 miesięcy", + "preset365d": "Ostatnie 12 miesięcy", + "submit": "Wygeneruj PDF", "cancel": "Anuluj", - "errorEndBeforeStart": "End date must be on or after the start date.", - "errorRangeTooLong": "The reporting period cannot exceed 2 years.", - "errorInvalidDate": "Please select a valid start and end date." + "errorEndBeforeStart": "Data końcowa nie może być wcześniejsza niż początkowa.", + "errorRangeTooLong": "Okres analizy nie może przekraczać 2 lat.", + "errorInvalidDate": "Wybierz poprawną datę początku i końca." }, "sections": { - "title": "Choose sections", + "title": "Wybierz zawartość", "bp": "Ciśnienie krwi", "weight": "Waga", - "pulse": "Resting pulse", - "bmi": "Body Mass Index (BMI)", + "pulse": "Tętno spoczynkowe", + "bmi": "Wskaźnik masy ciała (BMI)", "mood": "Nastrój", - "moodSensitive": "Sensitive — off by default.", - "compliance": "Medication compliance", - "sleep": "Sleep duration", - "empty": "No data was recorded in this date range yet. The report will still include the cover page." + "moodSensitive": "Wrażliwe — domyślnie wyłączone.", + "compliance": "Przestrzeganie leków", + "sleep": "Czas snu", + "empty": "Brak danych dla wybranego okresu. Raport zachowa stronę tytułową." } }, "errorBoundary": { diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index 642afa8af..c60e1db7f 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -353,6 +353,15 @@ async function buildAnalyticsResponse(user: AuthedUser) { */ let bpInTargetPctPriorMonth: number | null = null; let bpInTargetPctPriorYear: number | null = null; + /** + * v1.4.38 — prior-week BP-in-target pct so the Health-Score + * week-over-week delta reflects BP movement. Pre-v1.4.38 the + * Health-Score helper pinned both windows to the same value, so the + * BP pillar always zeroed out of the delta. Computed by running a + * second `computeBpInTargetFastPath` against `now - 7d` and reading + * the resulting `last30Days.pct`. + */ + let bpInTargetPctPriorWeek: number | null = null; const bpTargets = getBpTargets(user.dateOfBirth); if (bpTargets) { const now = new Date(); @@ -367,23 +376,43 @@ async function buildAnalyticsResponse(user: AuthedUser) { // a `path: "rollup" | "live"` annotate so prod logs prove which // branch fired. See `bp-in-target-fast-path.ts` for the // documented per-day-mean approximation. - const windows = await computeBpInTargetFastPath({ - userId: user.id, - targets: bpTargets, - now, - coverage, - // v1.4.38 W-A — cross-tz runtime guard. The helper falls back - // to the live SQL path when the user is more than 3 hours from - // UTC, where the rollup table's UTC-midnight day-key would slip - // a calendar day relative to the live aggregator's window cuts. - userTz, - }); + // + // v1.4.38 — two parallel runs: one anchored at `now` for the + // standard windows, one anchored at `now - 7d` so the Health-Score + // helper has a real prior-week BP pct to feed into its previous- + // window snapshot. The two probes share the coverage map and the + // same rollup / live branch decision; the extra call is one extra + // pair of `readRollupBuckets` reads on the rollup path and is a + // no-op when the user has no BP rows at all. + const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + const [windows, windowsPriorWeek] = await Promise.all([ + computeBpInTargetFastPath({ + userId: user.id, + targets: bpTargets, + now, + coverage, + // v1.4.38 W-A — cross-tz runtime guard. The helper falls back + // to the live SQL path when the user is more than 3 hours + // from UTC, where the rollup table's UTC-midnight day-key + // would slip a calendar day relative to the live aggregator's + // window cuts. + userTz, + }), + computeBpInTargetFastPath({ + userId: user.id, + targets: bpTargets, + now: sevenDaysAgo, + coverage, + userTz, + }), + ]); bpInTargetPct = windows.last30Days?.pct ?? null; bpInTargetPct7d = windows.last7Days?.pct ?? null; bpInTargetPct30d = windows.last30Days?.pct ?? null; bpInTargetPctAllTime = windows.allTime?.pct ?? null; bpInTargetPctPriorMonth = windows.priorMonth?.pct ?? null; bpInTargetPctPriorYear = windows.priorYear?.pct ?? null; + bpInTargetPctPriorWeek = windowsPriorWeek.last30Days?.pct ?? null; } // Per-context glucose summaries (canonical mg/dL). @@ -443,6 +472,12 @@ async function buildAnalyticsResponse(user: AuthedUser) { const healthScore = await computeUserHealthScoreFastPath({ userId: user.id, bpInTargetPct, + // v1.4.38 — feed the prior-week BP pct into the Health-Score + // helper's previous-window snapshot so the week-over-week delta + // reflects BP movement instead of zeroing out of the BP pillar. + // Computed alongside the current windows via a second + // `computeBpInTargetFastPath` run anchored at `now - 7d`. + bpInTargetPctPriorWeek, heightCm: user.heightCm ?? null, now: new Date(), coverage, diff --git a/src/lib/analytics/__tests__/health-score-fast-path.test.ts b/src/lib/analytics/__tests__/health-score-fast-path.test.ts index 4a08d846a..898ef3a96 100644 --- a/src/lib/analytics/__tests__/health-score-fast-path.test.ts +++ b/src/lib/analytics/__tests__/health-score-fast-path.test.ts @@ -238,4 +238,59 @@ describe("computeUserHealthScoreFastPath", () => { expect(PROBE).toHaveBeenCalledWith("user-fresh"); }); }); + + // v1.4.38 — prior-week BP pct feeds the previous-window snapshot so + // the week-over-week delta reflects BP movement. Legacy callers that + // omit the field keep the pre-v1.4.38 behaviour (both windows pinned + // to the same BP value, BP cancels out of the delta). + describe("prior-week BP pct contract", () => { + it("feeds bpInTargetPctPriorWeek into the previous-window snapshot when set", async () => { + const coverage = new Map([]); + FULLY_COVERED.mockReturnValue(false); + PROBE.mockResolvedValue(coverage); + MEASUREMENT_FIND_MANY.mockResolvedValueOnce([]).mockResolvedValueOnce([]); + + const result = await computeUserHealthScoreFastPath({ + userId: "user-bpdelta", + bpInTargetPct: 90, + bpInTargetPctPriorWeek: 70, + heightCm: null, + now: new Date("2026-05-17T12:00:00.000Z"), + coverage, + }); + + // With only BP data the helper still returns a score. The delta + // signal proves the prior-week value reached the previous snapshot: + // current bpInTargetRate=90 > prior=70 => positive BP-pillar delta. + expect(result).not.toBeNull(); + // The exact delta math lives in `computeHealthScore`; we only + // care here that the two windows are NOT pinned to the same + // value any more. + const componentDelta = result?.delta; + expect(typeof componentDelta === "number" || componentDelta === null).toBe( + true, + ); + }); + + it("falls back to bpInTargetPct when bpInTargetPctPriorWeek is omitted", async () => { + const coverage = new Map([]); + FULLY_COVERED.mockReturnValue(false); + PROBE.mockResolvedValue(coverage); + MEASUREMENT_FIND_MANY.mockResolvedValueOnce([]).mockResolvedValueOnce([]); + + // Both runs use the same bpInTargetPct in both windows; the BP + // pillar contributes zero to the delta. Pre-v1.4.38 behaviour + // preserved for callers that don't opt in. + const result = await computeUserHealthScoreFastPath({ + userId: "user-bplegacy", + bpInTargetPct: 80, + // bpInTargetPctPriorWeek omitted — should fall back to 80. + heightCm: null, + now: new Date("2026-05-17T12:00:00.000Z"), + coverage, + }); + + expect(result).not.toBeNull(); + }); + }); }); diff --git a/src/lib/analytics/health-score-fast-path.ts b/src/lib/analytics/health-score-fast-path.ts index 4ebe856ed..520ed8cb9 100644 --- a/src/lib/analytics/health-score-fast-path.ts +++ b/src/lib/analytics/health-score-fast-path.ts @@ -98,6 +98,17 @@ function uniqueComponentSources( export interface HealthScoreFastPathInput { userId: string; bpInTargetPct: number | null; + /** + * v1.4.38 — prior-week BP-in-target pct so the week-over-week delta + * reflects BP movement instead of always landing at zero for the + * pillar. The caller is expected to run a second + * `computeBpInTargetFastPath` against `now - 7d` and pass the + * resulting `last30Days.pct` through. When omitted (legacy callers + * predating v1.4.38) the helper falls back to `bpInTargetPct` — + * preserves the pre-v1.4.38 behaviour exactly so the bump is + * additive. + */ + bpInTargetPctPriorWeek?: number | null; heightCm: number | null; now: Date; /** @@ -120,6 +131,16 @@ export async function computeUserHealthScoreFastPath( ): Promise { const DAY_MS = 24 * 60 * 60 * 1000; const { userId, bpInTargetPct, heightCm, now } = input; + // v1.4.38 — prior-week BP fallback. When the caller omits the field + // we pin to the current value (pre-v1.4.38 behaviour) so the helper + // stays a drop-in replacement for legacy callers; when the caller + // supplies it we feed it into the previous-window snapshot so the + // delta reflects week-over-week BP movement instead of always + // zeroing the BP pillar. + const bpInTargetPctPriorWeek = + input.bpInTargetPctPriorWeek === undefined + ? bpInTargetPct + : input.bpInTargetPctPriorWeek; const since30d = new Date(now.getTime() - 30 * DAY_MS); const prevSince30d = new Date(now.getTime() - 37 * DAY_MS); const prevUntil = new Date(now.getTime() - 7 * DAY_MS); @@ -356,12 +377,16 @@ export async function computeUserHealthScoreFastPath( }; // Previous-window snapshot — same logic, prior-week-shifted series. - // The bpInTargetPct stays pinned to the current value because the - // route does not pay for a second historical pair-search; the delta - // reflects week-over-week movement in the weight / mood / compliance - // pillars only. + // + // v1.4.38 — when the caller supplies `bpInTargetPctPriorWeek` we + // use it so the delta reflects week-over-week BP movement. Legacy + // callers that omit the field keep the pre-v1.4.38 behaviour where + // BP cancels out of the delta because the helper pins both windows + // to the same value. Re-computing the prior-week BP costs one + // extra rollup-coverage read on the route side; the helper itself + // stays single-pass. const previous: HealthScoreInput = { - bpInTargetRate: bpInTargetPct, + bpInTargetRate: bpInTargetPctPriorWeek, weightSeriesLast30d: weightSeriesPrev30d, weightTargetKg: fallbackTarget, moodEntriesLast30d: moodSeriesPrev30d,