diff --git a/messages/de.json b/messages/de.json index 3aff7d309..1c92cceec 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4221,13 +4221,8 @@ "metricNextDayRecovery": "Erholung am Folgetag", "metricNextDayRestingHr": "Ruhepuls am Folgetag", "metricNextDayHrv": "Herzfrequenzvariabilität am Folgetag", - "unitHours": "Std.", - "unitKcal": "kcal", - "unitScore": "Pkt.", - "unitBpm": "bpm", - "unitMs": "ms", - "detailSameDay": "Durchschnittlich {withAvg} {unit} an den {withDays} Tagen mit diesem Tag gegenüber {withoutAvg} {unit} ohne.", - "detailNextDay": "Durchschnittlich {withAvg} {unit} am Tag nach den {withDays} Tagen mit diesem Tag gegenüber {withoutAvg} {unit} sonst." + "detailSameDay": "Durchschnittlich {withAvg} an den {withDays} Tagen mit diesem Tag gegenüber {withoutAvg} ohne.", + "detailNextDay": "Durchschnittlich {withAvg} am Tag nach den {withDays} Tagen mit diesem Tag gegenüber {withoutAvg} sonst." }, "factorCrosstab": { "title": "Deine bewerteten Faktoren und deine Vitalwerte", @@ -4239,15 +4234,8 @@ "metricHrv": "Herzfrequenzvariabilität", "metricWeight": "Gewicht", "metricBloodPressureSys": "Systolisch", - "unitHours": "Std.", - "unitScore": "Pkt.", - "unitSteps": "Schritte", - "unitBpm": "bpm", - "unitMs": "ms", - "unitKg": "kg", - "unitMmHg": "mmHg", - "detail": "Durchschnittlich {lowAvg} {unit} an den {lowDays} Tagen mit niedriger {factor}-Bewertung gegenüber {highAvg} {unit} an den Tagen mit hoher Bewertung.", - "detailInverse": "Durchschnittlich {lowAvg} {unit} an deinen {lowDays} schlechteren {factor}-Tagen gegenüber {highAvg} {unit} an deinen besseren." + "detail": "Durchschnittlich {lowAvg} an den {lowDays} Tagen mit niedriger {factor}-Bewertung gegenüber {highAvg} an den Tagen mit hoher Bewertung.", + "detailInverse": "Durchschnittlich {lowAvg} an deinen {lowDays} schlechteren {factor}-Tagen gegenüber {highAvg} an deinen besseren." }, "discovery": { "title": "Was auffällt", @@ -9158,9 +9146,9 @@ "insights": { "headline": { "title": "Dein Zyklus und dein Körper", - "restingHeartRate": "Dein Ruhepuls liegt in der Lutealphase etwa {delta} {unit} {dir} als in der Follikelphase.", - "heartRateVariability": "Deine Herzfrequenzvariabilität liegt in der Lutealphase etwa {delta} {unit} {dir} als in der Follikelphase.", - "generic": "Dein/e {metric} liegt in der Lutealphase etwa {delta} {unit} {dir} als in der Follikelphase.", + "restingHeartRate": "Dein Ruhepuls liegt in der Lutealphase etwa {delta} {dir} als in der Follikelphase.", + "heartRateVariability": "Deine Herzfrequenzvariabilität liegt in der Lutealphase etwa {delta} {dir} als in der Follikelphase.", + "generic": "Dein/e {metric} liegt in der Lutealphase etwa {delta} {dir} als in der Follikelphase.", "dirHigher": "höher", "dirLower": "niedriger", "empty": "Noch nicht genug Zyklen und Messwerte für ein Phasenmuster. Logge weiter, das füllt sich, je länger deine Historie wird." @@ -9168,7 +9156,7 @@ "crosstab": { "description": "Wie sich jeder Messwert zwischen deiner Luteal- und Follikelphase vergleicht, auf deinen eigenen Daten.", "pairLabel": "{metric}: Luteal- vs. Follikelphase", - "detail": "Luteal {lutealAvg} {unit} ({lutealDays} Tage) vs. Follikel {follicularAvg} {unit} ({follicularDays} Tage).", + "detail": "Luteal {lutealAvg} ({lutealDays} Tage) vs. Follikel {follicularAvg} ({follicularDays} Tage).", "metricRestingHeartRate": "Ruhepuls", "metricHrv": "Herzfrequenzvariabilität", "metricSleepDuration": "Schlafdauer", @@ -9178,12 +9166,7 @@ "metricWristTemperature": "Handgelenkstemperatur nachts", "metricSkinTemperature": "Hauttemperatur", "metricBloodGlucose": "Blutzucker", - "metricMood": "Stimmung", - "unitHours": "Std.", - "unitSteps": "Schritte", - "unitBpm": "S/min", - "unitMs": "ms", - "unitMood": "Pkt." + "metricMood": "Stimmung" }, "title": "Zyklus-Erkenntnisse", "symptomPatternsTitle": "Symptommuster nach Phase", diff --git a/messages/en.json b/messages/en.json index b58302e22..20d1ac6a4 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4221,13 +4221,8 @@ "metricNextDayRecovery": "Next-day recovery", "metricNextDayRestingHr": "Next-day resting heart rate", "metricNextDayHrv": "Next-day heart-rate variability", - "unitHours": "h", - "unitKcal": "kcal", - "unitScore": "pts", - "unitBpm": "bpm", - "unitMs": "ms", - "detailSameDay": "Averages {withAvg} {unit} on the {withDays} days you tagged this vs {withoutAvg} {unit} without it.", - "detailNextDay": "Averages {withAvg} {unit} the day after the {withDays} days you tagged this vs {withoutAvg} {unit} otherwise." + "detailSameDay": "Averages {withAvg} on the {withDays} days you tagged this vs {withoutAvg} without it.", + "detailNextDay": "Averages {withAvg} the day after the {withDays} days you tagged this vs {withoutAvg} otherwise." }, "factorCrosstab": { "title": "Your rated factors and your vitals", @@ -4239,15 +4234,8 @@ "metricHrv": "Heart-rate variability", "metricWeight": "Weight", "metricBloodPressureSys": "Systolic", - "unitHours": "h", - "unitScore": "pts", - "unitSteps": "steps", - "unitBpm": "bpm", - "unitMs": "ms", - "unitKg": "kg", - "unitMmHg": "mmHg", - "detail": "Averages {lowAvg} {unit} on the {lowDays} days you rated {factor} low vs {highAvg} {unit} on the days you rated it high.", - "detailInverse": "Averages {lowAvg} {unit} on your {lowDays} worse {factor} days vs {highAvg} {unit} on your better ones." + "detail": "Averages {lowAvg} on the {lowDays} days you rated {factor} low vs {highAvg} on the days you rated it high.", + "detailInverse": "Averages {lowAvg} on your {lowDays} worse {factor} days vs {highAvg} on your better ones." }, "discovery": { "title": "What stands out", @@ -9158,9 +9146,9 @@ "insights": { "headline": { "title": "Your cycle and your body", - "restingHeartRate": "Your resting heart rate runs about {delta} {unit} {dir} in your luteal phase than your follicular phase.", - "heartRateVariability": "Your heart-rate variability runs about {delta} {unit} {dir} in your luteal phase than your follicular phase.", - "generic": "Your {metric} runs about {delta} {unit} {dir} in your luteal phase than your follicular phase.", + "restingHeartRate": "Your resting heart rate runs about {delta} {dir} in your luteal phase than your follicular phase.", + "heartRateVariability": "Your heart-rate variability runs about {delta} {dir} in your luteal phase than your follicular phase.", + "generic": "Your {metric} runs about {delta} {dir} in your luteal phase than your follicular phase.", "dirHigher": "higher", "dirLower": "lower", "empty": "Not enough cycles and readings yet to surface a phase pattern. Keep logging; this fills in as your history grows." @@ -9168,7 +9156,7 @@ "crosstab": { "description": "How each measurement compares between your luteal and follicular phases, on your own data.", "pairLabel": "{metric}: luteal vs follicular", - "detail": "Luteal {lutealAvg} {unit} ({lutealDays} days) vs follicular {follicularAvg} {unit} ({follicularDays} days).", + "detail": "Luteal {lutealAvg} ({lutealDays} days) vs follicular {follicularAvg} ({follicularDays} days).", "metricRestingHeartRate": "Resting heart rate", "metricHrv": "Heart-rate variability", "metricSleepDuration": "Sleep duration", @@ -9178,12 +9166,7 @@ "metricWristTemperature": "Overnight wrist temperature", "metricSkinTemperature": "Skin temperature", "metricBloodGlucose": "Blood glucose", - "metricMood": "Mood", - "unitHours": "h", - "unitSteps": "steps", - "unitBpm": "bpm", - "unitMs": "ms", - "unitMood": "pts" + "metricMood": "Mood" }, "title": "Cycle insights", "symptomPatternsTitle": "Symptom patterns by phase", diff --git a/messages/es.json b/messages/es.json index a682b92ab..38c7f6f1c 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4221,13 +4221,8 @@ "metricNextDayRecovery": "Recuperación al día siguiente", "metricNextDayRestingHr": "Frecuencia cardíaca en reposo al día siguiente", "metricNextDayHrv": "Variabilidad de la frecuencia cardíaca al día siguiente", - "unitHours": "h", - "unitKcal": "kcal", - "unitScore": "pts", - "unitBpm": "ppm", - "unitMs": "ms", - "detailSameDay": "Promedia {withAvg} {unit} en los {withDays} días que lo etiquetaste frente a {withoutAvg} {unit} sin él.", - "detailNextDay": "Promedia {withAvg} {unit} el día después de los {withDays} días que lo etiquetaste frente a {withoutAvg} {unit} el resto." + "detailSameDay": "Promedia {withAvg} en los {withDays} días que lo etiquetaste frente a {withoutAvg} sin él.", + "detailNextDay": "Promedia {withAvg} el día después de los {withDays} días que lo etiquetaste frente a {withoutAvg} el resto." }, "factorCrosstab": { "title": "Tus factores valorados y tus constantes", @@ -4239,15 +4234,8 @@ "metricHrv": "Variabilidad de la frecuencia cardíaca", "metricWeight": "Peso", "metricBloodPressureSys": "Sistólica", - "unitHours": "h", - "unitScore": "pts", - "unitSteps": "pasos", - "unitBpm": "ppm", - "unitMs": "ms", - "unitKg": "kg", - "unitMmHg": "mmHg", - "detail": "Promedia {lowAvg} {unit} en los {lowDays} días que valoraste {factor} bajo frente a {highAvg} {unit} en los días que lo valoraste alto.", - "detailInverse": "Promedia {lowAvg} {unit} en tus {lowDays} peores días de {factor} frente a {highAvg} {unit} en los mejores." + "detail": "Promedia {lowAvg} en los {lowDays} días que valoraste {factor} bajo frente a {highAvg} en los días que lo valoraste alto.", + "detailInverse": "Promedia {lowAvg} en tus {lowDays} peores días de {factor} frente a {highAvg} en los mejores." }, "discovery": { "title": "Lo que destaca", @@ -9158,9 +9146,9 @@ "insights": { "headline": { "title": "Tu ciclo y tu cuerpo", - "restingHeartRate": "Tu frecuencia cardíaca en reposo es unos {delta} {unit} {dir} en tu fase lútea que en tu fase folicular.", - "heartRateVariability": "Tu variabilidad de la frecuencia cardíaca es unos {delta} {unit} {dir} en tu fase lútea que en tu fase folicular.", - "generic": "Tu {metric} es unos {delta} {unit} {dir} en tu fase lútea que en tu fase folicular.", + "restingHeartRate": "Tu frecuencia cardíaca en reposo es unos {delta} {dir} en tu fase lútea que en tu fase folicular.", + "heartRateVariability": "Tu variabilidad de la frecuencia cardíaca es unos {delta} {dir} en tu fase lútea que en tu fase folicular.", + "generic": "Tu {metric} es unos {delta} {dir} en tu fase lútea que en tu fase folicular.", "dirHigher": "más alta", "dirLower": "más baja", "empty": "Aún no hay suficientes ciclos ni registros para mostrar un patrón de fase. Sigue registrando: se completará a medida que crezca tu historial." @@ -9168,7 +9156,7 @@ "crosstab": { "description": "Cómo se compara cada medición entre tus fases lútea y folicular, con tus propios datos.", "pairLabel": "{metric}: fase lútea vs. folicular", - "detail": "Lútea {lutealAvg} {unit} ({lutealDays} días) vs. folicular {follicularAvg} {unit} ({follicularDays} días).", + "detail": "Lútea {lutealAvg} ({lutealDays} días) vs. folicular {follicularAvg} ({follicularDays} días).", "metricRestingHeartRate": "Frecuencia cardíaca en reposo", "metricHrv": "Variabilidad de la frecuencia cardíaca", "metricSleepDuration": "Duración del sueño", @@ -9178,12 +9166,7 @@ "metricWristTemperature": "Temperatura de la muñeca nocturna", "metricSkinTemperature": "Temperatura de la piel", "metricBloodGlucose": "Glucosa en sangre", - "metricMood": "Estado de ánimo", - "unitHours": "h", - "unitSteps": "pasos", - "unitBpm": "lpm", - "unitMs": "ms", - "unitMood": "pts" + "metricMood": "Estado de ánimo" }, "title": "Análisis del ciclo", "symptomPatternsTitle": "Patrones de síntomas por fase", diff --git a/messages/fr.json b/messages/fr.json index e0fd482d5..7f653d505 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4221,13 +4221,8 @@ "metricNextDayRecovery": "Récupération le lendemain", "metricNextDayRestingHr": "Fréquence cardiaque au repos le lendemain", "metricNextDayHrv": "Variabilité de la fréquence cardiaque le lendemain", - "unitHours": "h", - "unitKcal": "kcal", - "unitScore": "pts", - "unitBpm": "bpm", - "unitMs": "ms", - "detailSameDay": "En moyenne {withAvg} {unit} les {withDays} jours avec ce tag contre {withoutAvg} {unit} sans.", - "detailNextDay": "En moyenne {withAvg} {unit} le lendemain des {withDays} jours avec ce tag contre {withoutAvg} {unit} sinon." + "detailSameDay": "En moyenne {withAvg} les {withDays} jours avec ce tag contre {withoutAvg} sans.", + "detailNextDay": "En moyenne {withAvg} le lendemain des {withDays} jours avec ce tag contre {withoutAvg} sinon." }, "factorCrosstab": { "title": "Vos facteurs notés et vos constantes", @@ -4239,15 +4234,8 @@ "metricHrv": "Variabilité de la fréquence cardiaque", "metricWeight": "Poids", "metricBloodPressureSys": "Systolique", - "unitHours": "h", - "unitScore": "pts", - "unitSteps": "pas", - "unitBpm": "bpm", - "unitMs": "ms", - "unitKg": "kg", - "unitMmHg": "mmHg", - "detail": "En moyenne {lowAvg} {unit} les {lowDays} jours où vous avez noté {factor} bas contre {highAvg} {unit} les jours où vous l'avez noté haut.", - "detailInverse": "En moyenne {lowAvg} {unit} sur vos {lowDays} pires jours de {factor} contre {highAvg} {unit} sur les meilleurs." + "detail": "En moyenne {lowAvg} les {lowDays} jours où vous avez noté {factor} bas contre {highAvg} les jours où vous l'avez noté haut.", + "detailInverse": "En moyenne {lowAvg} sur vos {lowDays} pires jours de {factor} contre {highAvg} sur les meilleurs." }, "discovery": { "title": "Ce qui ressort", @@ -9158,9 +9146,9 @@ "insights": { "headline": { "title": "Ton cycle et ton corps", - "restingHeartRate": "Ta fréquence cardiaque au repos est d'environ {delta} {unit} {dir} en phase lutéale qu'en phase folliculaire.", - "heartRateVariability": "Ta variabilité de la fréquence cardiaque est d'environ {delta} {unit} {dir} en phase lutéale qu'en phase folliculaire.", - "generic": "Ton/ta {metric} est d'environ {delta} {unit} {dir} en phase lutéale qu'en phase folliculaire.", + "restingHeartRate": "Ta fréquence cardiaque au repos est d'environ {delta} {dir} en phase lutéale qu'en phase folliculaire.", + "heartRateVariability": "Ta variabilité de la fréquence cardiaque est d'environ {delta} {dir} en phase lutéale qu'en phase folliculaire.", + "generic": "Ton/ta {metric} est d'environ {delta} {dir} en phase lutéale qu'en phase folliculaire.", "dirHigher": "plus élevée", "dirLower": "plus basse", "empty": "Pas encore assez de cycles et de relevés pour faire ressortir un schéma de phase. Continue à enregistrer : cela se complétera à mesure que ton historique grandit." @@ -9168,7 +9156,7 @@ "crosstab": { "description": "Comment chaque mesure se compare entre tes phases lutéale et folliculaire, sur tes propres données.", "pairLabel": "{metric} : phase lutéale vs folliculaire", - "detail": "Lutéale {lutealAvg} {unit} ({lutealDays} jours) vs folliculaire {follicularAvg} {unit} ({follicularDays} jours).", + "detail": "Lutéale {lutealAvg} ({lutealDays} jours) vs folliculaire {follicularAvg} ({follicularDays} jours).", "metricRestingHeartRate": "Fréquence cardiaque au repos", "metricHrv": "Variabilité de la fréquence cardiaque", "metricSleepDuration": "Durée du sommeil", @@ -9178,12 +9166,7 @@ "metricWristTemperature": "Température du poignet la nuit", "metricSkinTemperature": "Température de la peau", "metricBloodGlucose": "Glycémie", - "metricMood": "Humeur", - "unitHours": "h", - "unitSteps": "pas", - "unitBpm": "bpm", - "unitMs": "ms", - "unitMood": "pts" + "metricMood": "Humeur" }, "title": "Analyses du cycle", "symptomPatternsTitle": "Tendances des symptômes par phase", diff --git a/messages/it.json b/messages/it.json index 19db4220d..6e3618cb8 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4221,13 +4221,8 @@ "metricNextDayRecovery": "Recupero il giorno dopo", "metricNextDayRestingHr": "Frequenza cardiaca a riposo il giorno dopo", "metricNextDayHrv": "Variabilità della frequenza cardiaca il giorno dopo", - "unitHours": "h", - "unitKcal": "kcal", - "unitScore": "pt", - "unitBpm": "bpm", - "unitMs": "ms", - "detailSameDay": "In media {withAvg} {unit} nei {withDays} giorni con questo tag contro {withoutAvg} {unit} senza.", - "detailNextDay": "In media {withAvg} {unit} il giorno dopo i {withDays} giorni con questo tag contro {withoutAvg} {unit} altrimenti." + "detailSameDay": "In media {withAvg} nei {withDays} giorni con questo tag contro {withoutAvg} senza.", + "detailNextDay": "In media {withAvg} il giorno dopo i {withDays} giorni con questo tag contro {withoutAvg} altrimenti." }, "factorCrosstab": { "title": "I tuoi fattori valutati e i tuoi parametri vitali", @@ -4239,15 +4234,8 @@ "metricHrv": "Variabilità della frequenza cardiaca", "metricWeight": "Peso", "metricBloodPressureSys": "Sistolica", - "unitHours": "h", - "unitScore": "pti", - "unitSteps": "passi", - "unitBpm": "bpm", - "unitMs": "ms", - "unitKg": "kg", - "unitMmHg": "mmHg", - "detail": "In media {lowAvg} {unit} nei {lowDays} giorni in cui hai valutato basso {factor} contro {highAvg} {unit} nei giorni in cui lo hai valutato alto.", - "detailInverse": "In media {lowAvg} {unit} nei tuoi {lowDays} giorni peggiori di {factor} contro {highAvg} {unit} in quelli migliori." + "detail": "In media {lowAvg} nei {lowDays} giorni in cui hai valutato basso {factor} contro {highAvg} nei giorni in cui lo hai valutato alto.", + "detailInverse": "In media {lowAvg} nei tuoi {lowDays} giorni peggiori di {factor} contro {highAvg} in quelli migliori." }, "discovery": { "title": "Cosa risalta", @@ -9158,9 +9146,9 @@ "insights": { "headline": { "title": "Il tuo ciclo e il tuo corpo", - "restingHeartRate": "La tua frequenza cardiaca a riposo è di circa {delta} {unit} {dir} nella fase luteale rispetto alla fase follicolare.", - "heartRateVariability": "La tua variabilità della frequenza cardiaca è di circa {delta} {unit} {dir} nella fase luteale rispetto alla fase follicolare.", - "generic": "Il/la tuo/a {metric} è di circa {delta} {unit} {dir} nella fase luteale rispetto alla fase follicolare.", + "restingHeartRate": "La tua frequenza cardiaca a riposo è di circa {delta} {dir} nella fase luteale rispetto alla fase follicolare.", + "heartRateVariability": "La tua variabilità della frequenza cardiaca è di circa {delta} {dir} nella fase luteale rispetto alla fase follicolare.", + "generic": "Il/la tuo/a {metric} è di circa {delta} {dir} nella fase luteale rispetto alla fase follicolare.", "dirHigher": "più alta", "dirLower": "più bassa", "empty": "Non ci sono ancora abbastanza cicli e rilevazioni per mostrare uno schema di fase. Continua a registrare: si completerà man mano che cresce il tuo storico." @@ -9168,7 +9156,7 @@ "crosstab": { "description": "Come si confronta ogni misurazione tra la fase luteale e quella follicolare, sui tuoi dati.", "pairLabel": "{metric}: fase luteale vs follicolare", - "detail": "Luteale {lutealAvg} {unit} ({lutealDays} giorni) vs follicolare {follicularAvg} {unit} ({follicularDays} giorni).", + "detail": "Luteale {lutealAvg} ({lutealDays} giorni) vs follicolare {follicularAvg} ({follicularDays} giorni).", "metricRestingHeartRate": "Frequenza cardiaca a riposo", "metricHrv": "Variabilità della frequenza cardiaca", "metricSleepDuration": "Durata del sonno", @@ -9178,12 +9166,7 @@ "metricWristTemperature": "Temperatura del polso notturna", "metricSkinTemperature": "Temperatura della pelle", "metricBloodGlucose": "Glicemia", - "metricMood": "Umore", - "unitHours": "h", - "unitSteps": "passi", - "unitBpm": "bpm", - "unitMs": "ms", - "unitMood": "pts" + "metricMood": "Umore" }, "title": "Analisi del ciclo", "symptomPatternsTitle": "Pattern dei sintomi per fase", diff --git a/messages/ko.json b/messages/ko.json index fb03673be..d1c43962a 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -4221,13 +4221,8 @@ "metricNextDayRecovery": "다음 날 회복도", "metricNextDayRestingHr": "다음 날 안정 시 심박수", "metricNextDayHrv": "다음 날 심박변이도", - "unitHours": "h", - "unitKcal": "kcal", - "unitScore": "점", - "unitBpm": "bpm", - "unitMs": "ms", - "detailSameDay": "이 태그를 붙인 {withDays}일의 평균은 {withAvg}{unit}, 붙이지 않은 날의 평균은 {withoutAvg}{unit}.", - "detailNextDay": "이 태그를 붙인 {withDays}일의 다음 날 평균은 {withAvg}{unit}, 그 외의 날은 {withoutAvg}{unit}." + "detailSameDay": "이 태그를 붙인 {withDays}일의 평균은 {withAvg}, 붙이지 않은 날의 평균은 {withoutAvg}.", + "detailNextDay": "이 태그를 붙인 {withDays}일의 다음 날 평균은 {withAvg}, 그 외의 날은 {withoutAvg}." }, "factorCrosstab": { "title": "직접 평가한 요인과 생체 지표", @@ -4239,15 +4234,8 @@ "metricHrv": "심박변이도", "metricWeight": "체중", "metricBloodPressureSys": "수축기", - "unitHours": "h", - "unitScore": "점", - "unitSteps": "걸음", - "unitBpm": "bpm", - "unitMs": "ms", - "unitKg": "kg", - "unitMmHg": "mmHg", - "detail": "{factor} 평가가 낮았던 {lowDays}일의 평균은 {lowAvg}{unit}, 높게 평가한 날의 평균은 {highAvg}{unit}.", - "detailInverse": "{factor} 상태가 나빴던 {lowDays}일의 평균은 {lowAvg}{unit}, 좋았던 날의 평균은 {highAvg}{unit}." + "detail": "{factor} 평가가 낮았던 {lowDays}일의 평균은 {lowAvg}, 높게 평가한 날의 평균은 {highAvg}.", + "detailInverse": "{factor} 상태가 나빴던 {lowDays}일의 평균은 {lowAvg}, 좋았던 날의 평균은 {highAvg}." }, "discovery": { "title": "눈에 띄는 점", @@ -9158,9 +9146,9 @@ "insights": { "headline": { "title": "내 주기와 내 몸", - "restingHeartRate": "황체기의 안정 시 심박수가 난포기보다 약 {delta}{unit} {dir}.", - "heartRateVariability": "황체기의 심박변이도가 난포기보다 약 {delta}{unit} {dir}.", - "generic": "황체기의 {metric} 값이 난포기보다 약 {delta}{unit} {dir}.", + "restingHeartRate": "황체기의 안정 시 심박수가 난포기보다 약 {delta} {dir}.", + "heartRateVariability": "황체기의 심박변이도가 난포기보다 약 {delta} {dir}.", + "generic": "황체기의 {metric} 값이 난포기보다 약 {delta} {dir}.", "dirHigher": "높아요", "dirLower": "낮아요", "empty": "아직 주기와 측정값이 부족해서 단계별 패턴을 보여줄 수 없어요. 계속 기록하면 기록이 쌓이면서 채워져요." @@ -9168,7 +9156,7 @@ "crosstab": { "description": "내 데이터를 기준으로, 각 측정값이 황체기와 난포기에서 어떻게 다른지 비교했어요.", "pairLabel": "{metric}: 황체기와 난포기 비교", - "detail": "황체기 {lutealAvg}{unit} ({lutealDays}일), 난포기 {follicularAvg}{unit} ({follicularDays}일).", + "detail": "황체기 {lutealAvg} ({lutealDays}일), 난포기 {follicularAvg} ({follicularDays}일).", "metricRestingHeartRate": "안정 시 심박수", "metricHrv": "심박변이도", "metricSleepDuration": "수면 시간", @@ -9178,12 +9166,7 @@ "metricWristTemperature": "야간 손목 온도", "metricSkinTemperature": "피부 온도", "metricBloodGlucose": "혈당", - "metricMood": "기분", - "unitHours": "h", - "unitSteps": "걸음", - "unitBpm": "bpm", - "unitMs": "ms", - "unitMood": "점" + "metricMood": "기분" }, "title": "주기 인사이트", "symptomPatternsTitle": "단계별 증상 패턴", diff --git a/messages/nl.json b/messages/nl.json index b70878026..f6540db5e 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -4221,13 +4221,8 @@ "metricNextDayRecovery": "Herstel de volgende dag", "metricNextDayRestingHr": "Rusthartslag de volgende dag", "metricNextDayHrv": "Hartslagvariabiliteit de volgende dag", - "unitHours": "u", - "unitKcal": "kcal", - "unitScore": "ptn", - "unitBpm": "bpm", - "unitMs": "ms", - "detailSameDay": "Gemiddeld {withAvg} {unit} op de {withDays} dagen dat je dit hebt getagd, tegenover {withoutAvg} {unit} zonder.", - "detailNextDay": "Gemiddeld {withAvg} {unit} op de dag na de {withDays} dagen dat je dit hebt getagd, tegenover {withoutAvg} {unit} op andere dagen." + "detailSameDay": "Gemiddeld {withAvg} op de {withDays} dagen dat je dit hebt getagd, tegenover {withoutAvg} zonder.", + "detailNextDay": "Gemiddeld {withAvg} op de dag na de {withDays} dagen dat je dit hebt getagd, tegenover {withoutAvg} op andere dagen." }, "factorCrosstab": { "title": "Je beoordeelde factoren en je vitale waarden", @@ -4239,15 +4234,8 @@ "metricHrv": "Hartslagvariabiliteit", "metricWeight": "Gewicht", "metricBloodPressureSys": "Systolisch", - "unitHours": "u", - "unitScore": "ptn", - "unitSteps": "stappen", - "unitBpm": "bpm", - "unitMs": "ms", - "unitKg": "kg", - "unitMmHg": "mmHg", - "detail": "Gemiddeld {lowAvg} {unit} op de {lowDays} dagen dat je {factor} laag beoordeelde, tegenover {highAvg} {unit} op de dagen dat je die hoog beoordeelde.", - "detailInverse": "Gemiddeld {lowAvg} {unit} op je {lowDays} slechtere dagen voor {factor}, tegenover {highAvg} {unit} op je betere dagen." + "detail": "Gemiddeld {lowAvg} op de {lowDays} dagen dat je {factor} laag beoordeelde, tegenover {highAvg} op de dagen dat je die hoog beoordeelde.", + "detailInverse": "Gemiddeld {lowAvg} op je {lowDays} slechtere dagen voor {factor}, tegenover {highAvg} op je betere dagen." }, "discovery": { "title": "Wat opvalt", @@ -9158,9 +9146,9 @@ "insights": { "headline": { "title": "Je cyclus en je lichaam", - "restingHeartRate": "Je rusthartslag ligt in je luteale fase ongeveer {delta} {unit} {dir} dan in je folliculaire fase.", - "heartRateVariability": "Je hartslagvariabiliteit ligt in je luteale fase ongeveer {delta} {unit} {dir} dan in je folliculaire fase.", - "generic": "Je {metric} ligt in je luteale fase ongeveer {delta} {unit} {dir} dan in je folliculaire fase.", + "restingHeartRate": "Je rusthartslag ligt in je luteale fase ongeveer {delta} {dir} dan in je folliculaire fase.", + "heartRateVariability": "Je hartslagvariabiliteit ligt in je luteale fase ongeveer {delta} {dir} dan in je folliculaire fase.", + "generic": "Je {metric} ligt in je luteale fase ongeveer {delta} {dir} dan in je folliculaire fase.", "dirHigher": "hoger", "dirLower": "lager", "empty": "Nog niet genoeg cycli en metingen om een patroon per fase te tonen. Blijf vastleggen; dit vult zich naarmate je geschiedenis groeit." @@ -9168,7 +9156,7 @@ "crosstab": { "description": "Hoe elke meting zich verhoudt tussen je luteale en folliculaire fase, op basis van je eigen gegevens.", "pairLabel": "{metric}: luteaal vs. folliculair", - "detail": "Luteaal {lutealAvg} {unit} ({lutealDays} dagen) vs. folliculair {follicularAvg} {unit} ({follicularDays} dagen).", + "detail": "Luteaal {lutealAvg} ({lutealDays} dagen) vs. folliculair {follicularAvg} ({follicularDays} dagen).", "metricRestingHeartRate": "Rusthartslag", "metricHrv": "Hartslagvariabiliteit", "metricSleepDuration": "Slaapduur", @@ -9178,12 +9166,7 @@ "metricWristTemperature": "Nachtelijke polstemperatuur", "metricSkinTemperature": "Huidtemperatuur", "metricBloodGlucose": "Bloedglucose", - "metricMood": "Stemming", - "unitHours": "u", - "unitSteps": "stappen", - "unitBpm": "bpm", - "unitMs": "ms", - "unitMood": "pnt" + "metricMood": "Stemming" }, "title": "Cyclusinzichten", "symptomPatternsTitle": "Symptoompatronen per fase", diff --git a/messages/pl.json b/messages/pl.json index 7c0d439f1..49f041e09 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4221,13 +4221,8 @@ "metricNextDayRecovery": "Regeneracja następnego dnia", "metricNextDayRestingHr": "Tętno spoczynkowe następnego dnia", "metricNextDayHrv": "Zmienność rytmu serca następnego dnia", - "unitHours": "godz.", - "unitKcal": "kcal", - "unitScore": "pkt", - "unitBpm": "bpm", - "unitMs": "ms", - "detailSameDay": "Średnio {withAvg} {unit} w {withDays} dniach z tym tagiem wobec {withoutAvg} {unit} bez niego.", - "detailNextDay": "Średnio {withAvg} {unit} dzień po {withDays} dniach z tym tagiem wobec {withoutAvg} {unit} w pozostałe dni." + "detailSameDay": "Średnio {withAvg} w {withDays} dniach z tym tagiem wobec {withoutAvg} bez niego.", + "detailNextDay": "Średnio {withAvg} dzień po {withDays} dniach z tym tagiem wobec {withoutAvg} w pozostałe dni." }, "factorCrosstab": { "title": "Twoje ocenione czynniki i parametry życiowe", @@ -4239,15 +4234,8 @@ "metricHrv": "Zmienność rytmu serca", "metricWeight": "Waga", "metricBloodPressureSys": "Skurczowe", - "unitHours": "godz.", - "unitScore": "pkt", - "unitSteps": "kroki", - "unitBpm": "bpm", - "unitMs": "ms", - "unitKg": "kg", - "unitMmHg": "mmHg", - "detail": "Średnio {lowAvg} {unit} w {lowDays} dniach z niską oceną {factor} wobec {highAvg} {unit} w dniach z wysoką oceną.", - "detailInverse": "Średnio {lowAvg} {unit} w Twoich {lowDays} gorszych dniach {factor} wobec {highAvg} {unit} w lepszych." + "detail": "Średnio {lowAvg} w {lowDays} dniach z niską oceną {factor} wobec {highAvg} w dniach z wysoką oceną.", + "detailInverse": "Średnio {lowAvg} w Twoich {lowDays} gorszych dniach {factor} wobec {highAvg} w lepszych." }, "discovery": { "title": "Co się wyróżnia", @@ -9158,9 +9146,9 @@ "insights": { "headline": { "title": "Twój cykl i Twoje ciało", - "restingHeartRate": "Twoje tętno spoczynkowe jest o około {delta} {unit} {dir} w fazie lutealnej niż w fazie folikularnej.", - "heartRateVariability": "Twoja zmienność rytmu serca jest o około {delta} {unit} {dir} w fazie lutealnej niż w fazie folikularnej.", - "generic": "Twoje {metric} jest o około {delta} {unit} {dir} w fazie lutealnej niż w fazie folikularnej.", + "restingHeartRate": "Twoje tętno spoczynkowe jest o około {delta} {dir} w fazie lutealnej niż w fazie folikularnej.", + "heartRateVariability": "Twoja zmienność rytmu serca jest o około {delta} {dir} w fazie lutealnej niż w fazie folikularnej.", + "generic": "Twoje {metric} jest o około {delta} {dir} w fazie lutealnej niż w fazie folikularnej.", "dirHigher": "wyższe", "dirLower": "niższe", "empty": "Za mało cykli i pomiarów, aby pokazać wzorzec fazy. Rejestruj dalej, a uzupełni się to wraz z historią." @@ -9168,7 +9156,7 @@ "crosstab": { "description": "Jak każdy pomiar wypada między fazą lutealną a folikularną, na Twoich danych.", "pairLabel": "{metric}: faza lutealna vs folikularna", - "detail": "Lutealna {lutealAvg} {unit} ({lutealDays} dni) vs folikularna {follicularAvg} {unit} ({follicularDays} dni).", + "detail": "Lutealna {lutealAvg} ({lutealDays} dni) vs folikularna {follicularAvg} ({follicularDays} dni).", "metricRestingHeartRate": "Tętno spoczynkowe", "metricHrv": "Zmienność rytmu serca", "metricSleepDuration": "Czas snu", @@ -9178,12 +9166,7 @@ "metricWristTemperature": "Nocna temperatura nadgarstka", "metricSkinTemperature": "Temperatura skóry", "metricBloodGlucose": "Glukoza we krwi", - "metricMood": "Nastrój", - "unitHours": "godz.", - "unitSteps": "kroki", - "unitBpm": "ud./min", - "unitMs": "ms", - "unitMood": "pkt" + "metricMood": "Nastrój" }, "title": "Analizy cyklu", "symptomPatternsTitle": "Wzorce objawów wg fazy", diff --git a/src/__tests__/helpers/metric-unit-display.ts b/src/__tests__/helpers/metric-unit-display.ts new file mode 100644 index 000000000..0b3971e0e --- /dev/null +++ b/src/__tests__/helpers/metric-unit-display.ts @@ -0,0 +1,24 @@ +/** + * A `useUnitDisplay` result for the metric preference, for static renders + * that have no QueryClient (the real hook hangs off `useAuth`). Metric is the + * identity on values and yields the canonical display symbols. + */ +import * as dt from "@/lib/measurements/display-transform"; +import type { UnitDisplay } from "@/hooks/use-unit-display"; + +export function metricUnitDisplay( + glucoseUnit: UnitDisplay["glucoseUnit"] = "mg/dL", +): UnitDisplay { + const forType = (t: string) => dt.getDisplayTransform(t, "metric"); + return { + preference: "metric", + glucoseUnit, + transformFor: forType, + toDisplay: (t, v) => dt.applyDisplayTransform(v, forType(t)), + fromDisplay: (t, v) => dt.invertDisplayTransform(v, forType(t)), + toDisplayDelta: (t, v) => dt.applyDisplayTransformDelta(v, forType(t)), + unitFor: (t) => forType(t).displayUnit, + decimalsFor: (t) => forType(t).decimals, + isTransformed: (t) => dt.hasDisplayTransform(t), + }; +} diff --git a/src/__tests__/unit-preference-display-guard.test.ts b/src/__tests__/unit-preference-display-guard.test.ts index 4f6362757..b5febe503 100644 --- a/src/__tests__/unit-preference-display-guard.test.ts +++ b/src/__tests__/unit-preference-display-guard.test.ts @@ -328,8 +328,14 @@ const UNIT_LITERAL_ALLOWLIST: Record< reason: "a display-kind tag; the weight row converts through the hook", }, "components/insights/mood/mood-factor-metric-crosstab.tsx": { - lines: 2, - reason: "a display-kind tag; the weight row converts through the hook", + lines: 1, + reason: + "a display-kind tag; the weight row converts through crosstabReading", + }, + "components/insights/metric-crosstab.tsx": { + lines: 3, + reason: + "the boards' display kinds and the storage token each is in; rows convert through getReadingTransform and label through measurementUnitLabel", }, // ── Canonical by contract ── "lib/insights/metric-status-registry.ts": { diff --git a/src/components/cycle/__tests__/cycle-phase-crosstab.test.tsx b/src/components/cycle/__tests__/cycle-phase-crosstab.test.tsx index 1e16d70e6..70e93fd0d 100644 --- a/src/components/cycle/__tests__/cycle-phase-crosstab.test.tsx +++ b/src/components/cycle/__tests__/cycle-phase-crosstab.test.tsx @@ -86,7 +86,9 @@ describe("", () => { ); expect(html).toContain("Mood"); expect(html).toContain("Blood glucose"); - expect(html).toContain("pts"); + // A mood rating has no unit word, as everywhere else a rating is shown. + expect(html).not.toContain("pts"); + expect(html).toContain("Luteal 5.0 (12 days)"); expect(html).toContain("mg/dL"); // Never leaks the raw metric key or the raw i18n key on a covered row. expect(html).not.toContain("cycle.insights.crosstab"); diff --git a/src/components/cycle/cycle-phase-crosstab.tsx b/src/components/cycle/cycle-phase-crosstab.tsx index a46a12917..4762dcdaf 100644 --- a/src/components/cycle/cycle-phase-crosstab.tsx +++ b/src/components/cycle/cycle-phase-crosstab.tsx @@ -2,13 +2,13 @@ import { useFormatters, useTranslations } from "@/lib/i18n/context"; import type { Formatters } from "@/lib/format-locale"; -import { useUnitDisplay, type UnitDisplay } from "@/hooks/use-unit-display"; +import { useUnitDisplay } from "@/hooks/use-unit-display"; import { - applyDisplayTransform, - applyDisplayTransformDelta, - getReadingTransform, -} from "@/lib/measurements/display-transform"; -import { MetricCrosstab } from "@/components/insights/metric-crosstab"; + crosstabReading, + MetricCrosstab, + withUnit, + type CrosstabMeasure, +} from "@/components/insights/metric-crosstab"; /** * v1.15 — CYCLE_PHASE × vital crosstab card. @@ -57,18 +57,26 @@ const METRIC_LABEL_KEY: Record = { }; /** - * Bundle labels for the displays no preference changes. Weight, the - * temperatures and glucose take their symbol from the reader's transform - * (`DISPLAY_TO_TYPE` below) and have no bundle label to fall back on. + * The measurement each row reads (`src/lib/cycle/phase-crosstab.ts`), which + * is what its unit and its preference conversion come from. Mood has none. */ -const UNIT_KEY: Partial> = { - hours: "cycle.insights.crosstab.unitHours", - steps: "cycle.insights.crosstab.unitSteps", - bpm: "cycle.insights.crosstab.unitBpm", - ms: "cycle.insights.crosstab.unitMs", - mood: "cycle.insights.crosstab.unitMood", +const METRIC_TYPE: Record = { + restingHeartRate: "RESTING_HEART_RATE", + heartRateVariability: "HEART_RATE_VARIABILITY", + sleepDuration: "SLEEP_DURATION", + steps: "ACTIVITY_STEPS", + weight: "WEIGHT", + basalBodyTemp: "BODY_TEMPERATURE", + wristTemperature: "WRIST_TEMPERATURE", + skinTemperature: "SKIN_TEMPERATURE", + bloodGlucose: "BLOOD_GLUCOSE", + mood: null, }; +function measureOf(row: CyclePhaseCrosstabRow): CrosstabMeasure { + return { type: METRIC_TYPE[row.metricKey] ?? null, display: row.display }; +} + /** Whole numbers for steps; one decimal otherwise. */ function fmt( value: number, @@ -78,57 +86,6 @@ function fmt( return display === "steps" ? nf.integer(value) : nf.number(value, 1); } -/** - * v1.32.26 — the two displays whose values follow the metric/imperial - * preference, mapped to the measurement type that carries the transform. Weight - * is offset-free (kg→lb); temperature is affine (°C→°F), so an ABSOLUTE phase - * average takes `toDisplay` while a phase DELTA takes `toDisplayDelta` (the - * offset must never leak into a difference). - */ -const DISPLAY_TO_TYPE: Partial> = { - kg: "WEIGHT", - celsius: "BODY_TEMPERATURE", - glucose: "BLOOD_GLUCOSE", -}; - -/** - * Unit symbol and conversions for one row, from the reader's preferences. - * Glucose follows the glucose unit (it used to print the mg/dL label from - * the bundle beside raw mg/dL values for every reader); weight and - * temperature follow metric/imperial; every other display keeps its bundle - * label and its raw value. - */ -function rowUnits( - display: CyclePhaseCrosstabDisplay, - unitDisplay: UnitDisplay, - t: (key: string) => string, -): { - unit: string; - abs: (v: number) => number; - delta: (v: number) => number; -} { - const type = DISPLAY_TO_TYPE[display]; - if (!type) { - // Defensive: an unmapped display must never pass undefined to `t` - // (whose resolver does `key.split(".")`). Empty unit on a miss. - const unitKey = UNIT_KEY[display]; - return { - unit: unitKey ? t(unitKey) : "", - abs: (v) => v, - delta: (v) => v, - }; - } - const transform = getReadingTransform(type, { - system: unitDisplay.preference, - glucoseUnit: unitDisplay.glucoseUnit, - }); - return { - unit: transform.displayUnit, - abs: (v) => applyDisplayTransform(v, transform), - delta: (v) => applyDisplayTransformDelta(v, transform), - }; -} - const HEADLINE_KEY: Record = { restingHeartRate: "cycle.insights.headline.restingHeartRate", heartRateVariability: "cycle.insights.headline.heartRateVariability", @@ -160,7 +117,11 @@ export function CyclePhaseHeadline({ const metricLabel = t( METRIC_LABEL_KEY[headline.metricKey] ?? headline.metricKey, ); - const { unit, delta: deltaConv } = rowUnits(headline.display, unitDisplay, t); + const { unit, delta: deltaConv } = crosstabReading( + measureOf(headline), + unitDisplay, + t, + ); const up = headline.delta >= 0; const dir = t( up @@ -170,8 +131,10 @@ export function CyclePhaseHeadline({ const params = { metric: metricLabel, // A phase delta → factor-only conversion (no affine offset). - delta: fmt(Math.abs(deltaConv(headline.delta)), headline.display, nf), - unit, + delta: withUnit( + fmt(Math.abs(deltaConv(headline.delta)), headline.display, nf), + unit, + ), dir, }; const key = @@ -202,11 +165,12 @@ export function CyclePhaseCrosstab({ description={t("cycle.insights.crosstab.description")} rows={rows.map((row) => { const metricLabel = t(METRIC_LABEL_KEY[row.metricKey] ?? row.metricKey); + const measure = measureOf(row); const { unit, abs: absConv, delta: deltaConv, - } = rowUnits(row.display, unitDisplay, t); + } = crosstabReading(measure, unitDisplay, t); // `delta` = lutealAvg − follicularAvg: positive = the vital runs // higher in the luteal phase. return { @@ -217,13 +181,18 @@ export function CyclePhaseCrosstab({ }), up: row.delta >= 0, deltaValue: fmt(deltaConv(row.delta), row.display, nf), - unit, + measure, confidence: row.confidence, detail: t("cycle.insights.crosstab.detail", { metric: metricLabel, - lutealAvg: fmt(absConv(row.lutealAvg), row.display, nf), - follicularAvg: fmt(absConv(row.follicularAvg), row.display, nf), - unit, + lutealAvg: withUnit( + fmt(absConv(row.lutealAvg), row.display, nf), + unit, + ), + follicularAvg: withUnit( + fmt(absConv(row.follicularAvg), row.display, nf), + unit, + ), lutealDays: row.lutealDays, follicularDays: row.follicularDays, }), diff --git a/src/components/insights/__tests__/metric-crosstab.test.tsx b/src/components/insights/__tests__/metric-crosstab.test.tsx index 7fd1f334a..12fa7baff 100644 --- a/src/components/insights/__tests__/metric-crosstab.test.tsx +++ b/src/components/insights/__tests__/metric-crosstab.test.tsx @@ -1,7 +1,13 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; import { Moon } from "lucide-react"; +vi.mock("@/hooks/use-unit-display", async () => { + const { metricUnitDisplay } = + await import("@/__tests__/helpers/metric-unit-display"); + return { useUnitDisplay: () => metricUnitDisplay() }; +}); + import { I18nProvider } from "@/lib/i18n/context"; import { MetricCrosstab, type MetricCrosstabRow } from "../metric-crosstab"; @@ -12,7 +18,7 @@ function row(overrides: Partial = {}): MetricCrosstabRow { pairLabel: "Resting heart rate", up: true, deltaValue: "2,5", - unit: "bpm", + measure: { type: "RESTING_HEART_RATE", display: "bpm" }, confidence: "medium", detail: "62 vs 59.5 bpm", ...overrides, @@ -68,4 +74,31 @@ describe("", () => { const html = render([row({ patternId: "p1", dismissed: true })]); expect(html).not.toContain('data-slot="board-row"'); }); + + it("takes the unit from the measure, through the shared unit table", () => { + const html = render([ + row(), + row({ + key: "b", + measure: { type: "ACTIVITY_STEPS", display: "steps" }, + deltaValue: "120", + }), + row({ + key: "c", + measure: { type: null, display: "mood" }, + deltaValue: "0,4", + }), + ]); + expect(html).toContain("+2,5 bpm"); + // The word comes from `measurementUnitLabel` (`insights.units.steps`). + expect(html).toContain("+120 steps"); + // A rating has no unit word: the number stands alone, no trailing space. + expect(html).toContain(">+0,4<"); + }); + + it("wraps a long pair label instead of cutting it", () => { + const html = render([row({ pairLabel: "A very long custom mood tag" })]); + expect(html).toMatch(/data-slot="metric-crosstab-pair"/); + expect(html).not.toContain("truncate"); + }); }); diff --git a/src/components/insights/metric-crosstab.tsx b/src/components/insights/metric-crosstab.tsx index 3211ff5d3..3f3ceb66e 100644 --- a/src/components/insights/metric-crosstab.tsx +++ b/src/components/insights/metric-crosstab.tsx @@ -3,7 +3,15 @@ import type { LucideIcon } from "lucide-react"; import { CONFIDENCE_TONE, ScorePill } from "@/components/ui/score-pill"; +import { useUnitDisplay, type UnitDisplay } from "@/hooks/use-unit-display"; import { useTranslations } from "@/lib/i18n/context"; +import { + applyDisplayTransform, + applyDisplayTransformDelta, + getReadingTransform, + isUnitSensitiveType, +} from "@/lib/measurements/display-transform"; +import { measurementUnitLabel } from "@/lib/measurements/unit-label"; import { PatternDismissButton, PatternDismissedNotice, @@ -18,14 +26,101 @@ import { * same markup that had begun to differ (the cycle copy printed its numbers * with a full stop in every language). * - * The caller owns the meaning: it resolves the labels, converts the units and - * formats the numbers (`deltaValue` already in the reader's notation, signed - * when negative). This component owns the shape: the row, the neutral delta - * (a higher value is good on one row and bad on the next, so the number is - * never coloured; the sign and `data-direction` carry the direction), the - * confidence chip, and the optional per-row dismissal. + * The caller owns the meaning: it resolves the labels and formats the numbers + * (`deltaValue` already in the reader's notation, signed when negative). The + * unit is not the caller's: each row names the measurement it reads + * (`measure`), and the unit comes from `measurementUnitLabel`, the one table + * every other read-out uses, after the reader's unit preference + * ({@link crosstabReading}). The boards used to carry a unit table each, and + * the German cycle board printed "S/min" where every other screen says "bpm". + * This component owns the shape: the row, the neutral delta (a higher value + * is good on one row and bad on the next, so the number is never coloured; + * the sign and `data-direction` carry the direction), the confidence chip, + * and the optional per-row dismissal. */ +/** + * How the server sends a board's values: the scale, not the label. Most are + * the measurement's canonical unit; sleep arrives in hours (stored minutes), + * and a mood value is a rating with no measurement type. + */ +export type CrosstabDisplay = + | "hours" + | "kcal" + | "score" + | "bpm" + | "ms" + | "steps" + | "kg" + | "mmHg" + | "celsius" + | "glucose" + | "mood"; + +/** The storage token each display is in, for `measurementUnitLabel`. */ +const DISPLAY_TOKEN: Record = { + hours: "h", + kcal: "kcal", + score: "score", + bpm: "bpm", + ms: "ms", + steps: "steps", + kg: "kg", + mmHg: "mmHg", + celsius: "celsius", + glucose: "mg/dL", + mood: "rating", +}; + +/** What one row reads: the measurement type (null for mood) and its scale. */ +export interface CrosstabMeasure { + type: string | null; + display: CrosstabDisplay; +} + +/** + * The unit a row reads in, and the conversions of its absolute values and + * its differences, under the reader's preferences. A type whose number + * follows a preference (weight, temperature, glucose) takes the transform; any + * other keeps the server's value. Either way the label is + * `measurementUnitLabel`'s. + */ +export function crosstabReading( + measure: CrosstabMeasure, + units: UnitDisplay, + t: (key: string) => string, +): { + unit: string; + abs: (value: number) => number; + delta: (value: number) => number; +} { + const { type } = measure; + if (type && isUnitSensitiveType(type)) { + // The reading transform, not `units.transformFor`: glucose follows its + // own unit preference, not metric/imperial. + const transform = getReadingTransform(type, { + system: units.preference, + glucoseUnit: units.glucoseUnit, + }); + return { + unit: measurementUnitLabel(type, transform.displayUnit, t), + abs: (value) => applyDisplayTransform(value, transform), + // A difference takes the factor only, never an affine offset (°C→°F). + delta: (value) => applyDisplayTransformDelta(value, transform), + }; + } + return { + unit: measurementUnitLabel(type ?? "", DISPLAY_TOKEN[measure.display], t), + abs: (value) => value, + delta: (value) => value, + }; +} + +/** A formatted value with its unit, or the value alone when it has none. */ +export function withUnit(value: string, unit: string): string { + return unit ? `${value} ${unit}` : value; +} + export type MetricCrosstabConfidence = "low" | "medium" | "high"; export interface MetricCrosstabRow { @@ -39,7 +134,8 @@ export interface MetricCrosstabRow { up: boolean; /** The difference in the reader's notation, with its minus sign. */ deltaValue: string; - unit: string; + /** What the row reads; the unit beside the difference comes from it. */ + measure: CrosstabMeasure; confidence: MetricCrosstabConfidence; detail: string; /** A dismissible pattern; omitted on boards that cannot be dismissed. */ @@ -68,6 +164,7 @@ export function MetricCrosstab({ rows: readonly MetricCrosstabRow[]; }) { const { t } = useTranslations(); + const units = useUnitDisplay(); const dismissal = usePatternDismissalOverrides(); if (rows.length === 0) return null; @@ -93,6 +190,7 @@ export function MetricCrosstab({ ); } const Icon = row.icon; + const { unit } = crosstabReading(row.measure, units, t); return (
  • -
    +
    {Icon ? (