fix(insights): take the crosstab units from the shared unit table

The cycle, mood-tag and mood-factor boards each kept a table of unit
words, and the German cycle board printed S/min where every other screen
says bpm. A board row now names the measurement it reads; MetricCrosstab
resolves the unit through measurementUnitLabel after the reader's unit
preference, and the callers use the same reading for their detail line.
A rating or score shows no unit word, as everywhere else. The boards' own
unit keys are gone from all bundles, and unit-label.test.ts refuses a
component that keeps a unit table again. The pair label wraps instead of
being cut off.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 16:16:13 +02:00
1 parent 5185edca74
commit a37d45ab3f
18 files changed
+426 -337

No files matched your search

+9 -26
View File
@@ -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",
+9 -26
View File
@@ -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",
+9 -26
View File
@@ -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",
+9 -26
View File
@@ -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",
+9 -26
View File
@@ -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",
+9 -26
View File
@@ -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": "단계별 증상 패턴",
+9 -26
View File
@@ -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",
+9 -26
View File
@@ -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",
@@ -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),
};
}
@@ -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": {
@@ -86,7 +86,9 @@ describe("<CyclePhaseCrosstab>", () => {
);
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");
+43 -74
View File
@@ -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<string, string> = {
};
/**
* 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<Record<CyclePhaseCrosstabDisplay, string>> = {
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<string, string | null> = {
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<Record<CyclePhaseCrosstabDisplay, string>> = {
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<string, string> = {
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,
}),
@@ -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> = {}): 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("<MetricCrosstab>", () => {
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");
});
});
+111 -12
View File
@@ -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<CrosstabDisplay, string> = {
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 (
<li
key={row.key}
@@ -102,21 +200,22 @@ export function MetricCrosstab({
data-direction={row.up ? "up" : "down"}
data-confidence={row.confidence}
>
<div className="flex items-center gap-2 text-sm">
<div className="flex items-start gap-2 text-sm">
{Icon ? (
<Icon
className="text-muted-foreground size-4 shrink-0"
className="text-muted-foreground mt-0.5 size-4 shrink-0"
aria-hidden="true"
/>
) : null}
{/* The pair wraps rather than cutting a tag name short. */}
<span
className="text-foreground min-w-0 flex-1 truncate"
title={row.pairLabel}
className="text-foreground min-w-0 flex-1 break-words"
data-slot="metric-crosstab-pair"
>
{row.pairLabel}
</span>
<span className="text-foreground shrink-0 text-sm font-semibold tabular-nums">
{`${row.up ? "+" : ""}${row.deltaValue} ${row.unit}`.trim()}
{withUnit(`${row.up ? "+" : ""}${row.deltaValue}`, unit)}
</span>
<ScorePill tone={CONFIDENCE_TONE[row.confidence]}>
{t(CONFIDENCE_KEY[row.confidence])}
@@ -1,6 +1,12 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
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 {
MoodTagMetricCrosstab,
@@ -4,7 +4,12 @@ import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { Formatters } from "@/lib/format-locale";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { moodTagIcon } from "@/components/mood/mood-tag-icons";
import { MetricCrosstab } from "@/components/insights/metric-crosstab";
import {
crosstabReading,
MetricCrosstab,
withUnit,
type CrosstabMeasure,
} from "@/components/insights/metric-crosstab";
import type { MoodInfluenceConfidence } from "./mood-tag-influence";
/**
@@ -57,14 +62,14 @@ const METRIC_LABEL_KEY: Record<string, string> = {
bloodPressureSystolic: "insights.mood.factorCrosstab.metricBloodPressureSys",
};
const UNIT_KEY: Record<MoodFactorCrosstabDisplay, string> = {
hours: "insights.mood.factorCrosstab.unitHours",
score: "insights.mood.factorCrosstab.unitScore",
steps: "insights.mood.factorCrosstab.unitSteps",
bpm: "insights.mood.factorCrosstab.unitBpm",
ms: "insights.mood.factorCrosstab.unitMs",
kg: "insights.mood.factorCrosstab.unitKg",
mmHg: "insights.mood.factorCrosstab.unitMmHg",
/** The measurement each row reads (`src/lib/insights/mood-crosstab.ts`). */
const METRIC_TYPE: Record<string, string> = {
sleepDuration: "SLEEP_DURATION",
steps: "ACTIVITY_STEPS",
restingHeartRate: "RESTING_HEART_RATE",
heartRateVariability: "HEART_RATE_VARIABILITY",
weight: "WEIGHT",
bloodPressureSystolic: "BLOOD_PRESSURE_SYS",
};
/** Whole numbers for steps; one decimal otherwise. */
@@ -92,16 +97,13 @@ export function MoodFactorMetricCrosstab({
rows={rows.map((row) => {
const factorLabel = t(row.labelKey);
const metricLabel = t(METRIC_LABEL_KEY[row.metricKey] ?? row.metricKey);
// The weight ("kg") row follows the metric/imperial preference: the
// unit symbol and the values (averages and delta, all in kg,
// offset-free) convert together. Every other display is unitless to
// the preference and keeps its i18n label and raw value.
const isWeightRow = row.display === "kg";
const unit = isWeightRow
? unitDisplay.unitFor("WEIGHT")
: t(UNIT_KEY[row.display]);
const conv = (v: number) =>
isWeightRow ? unitDisplay.toDisplay("WEIGHT", v) : v;
// Weight follows the metric/imperial preference (values and unit
// together); every other row keeps the server's value.
const measure: CrosstabMeasure = {
type: METRIC_TYPE[row.metricKey] ?? null,
display: row.display,
};
const { unit, abs, delta } = crosstabReading(measure, unitDisplay, t);
// `delta` = lowAvg − highAvg: positive = the vital runs higher on the
// low-factor (worse) days.
return {
@@ -113,8 +115,8 @@ export function MoodFactorMetricCrosstab({
}),
icon: moodTagIcon(row.icon),
up: row.delta >= 0,
deltaValue: formatValue(conv(row.delta), row.display, nf),
unit,
deltaValue: formatValue(delta(row.delta), row.display, nf),
measure,
confidence: row.confidence,
detail: t(
row.inverse
@@ -123,9 +125,14 @@ export function MoodFactorMetricCrosstab({
{
factor: factorLabel,
metric: metricLabel,
lowAvg: formatValue(conv(row.lowAvg), row.display, nf),
highAvg: formatValue(conv(row.highAvg), row.display, nf),
unit,
lowAvg: withUnit(
formatValue(abs(row.lowAvg), row.display, nf),
unit,
),
highAvg: withUnit(
formatValue(abs(row.highAvg), row.display, nf),
unit,
),
lowDays: row.lowDays,
},
),
@@ -3,7 +3,13 @@
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { Formatters } from "@/lib/format-locale";
import { moodTagIcon } from "@/components/mood/mood-tag-icons";
import { MetricCrosstab } from "@/components/insights/metric-crosstab";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import {
crosstabReading,
MetricCrosstab,
withUnit,
type CrosstabMeasure,
} from "@/components/insights/metric-crosstab";
import type { MoodInfluenceConfidence } from "./mood-tag-influence";
/**
@@ -63,12 +69,13 @@ const METRIC_LABEL_KEY: Record<string, string> = {
nextDayHeartRateVariability: "insights.mood.crosstab.metricNextDayHrv",
};
const UNIT_KEY: Record<MoodCrosstabDisplay, string> = {
hours: "insights.mood.crosstab.unitHours",
kcal: "insights.mood.crosstab.unitKcal",
score: "insights.mood.crosstab.unitScore",
bpm: "insights.mood.crosstab.unitBpm",
ms: "insights.mood.crosstab.unitMs",
/** The measurement each row reads (`src/lib/insights/mood-crosstab.ts`). */
const METRIC_TYPE: Record<string, string> = {
activeEnergy: "ACTIVE_ENERGY_BURNED",
sleepDuration: "SLEEP_DURATION",
nextDayRecovery: "RECOVERY_SCORE",
nextDayRestingHeartRate: "RESTING_HEART_RATE",
nextDayHeartRateVariability: "HEART_RATE_VARIABILITY",
};
/** One decimal for hours/score, whole numbers for kcal. */
@@ -87,6 +94,7 @@ export function MoodTagMetricCrosstab({
}) {
const { t } = useTranslations();
const nf = useFormatters();
const units = useUnitDisplay();
return (
<MetricCrosstab
slot="mood-tag-metric-crosstab"
@@ -95,7 +103,11 @@ export function MoodTagMetricCrosstab({
rows={rows.map((row) => {
const tagLabel = row.label ?? t(row.labelKey);
const metricLabel = t(METRIC_LABEL_KEY[row.metricKey] ?? row.metricKey);
const unit = t(UNIT_KEY[row.display]);
const measure: CrosstabMeasure = {
type: METRIC_TYPE[row.metricKey] ?? null,
display: row.display,
};
const { unit, abs, delta } = crosstabReading(measure, units, t);
return {
key: `${row.metricKey}:${row.tag}`,
metricKey: row.metricKey,
@@ -105,17 +117,22 @@ export function MoodTagMetricCrosstab({
}),
icon: moodTagIcon(row.icon),
up: row.delta >= 0,
deltaValue: formatValue(row.delta, row.display, nf),
unit,
deltaValue: formatValue(delta(row.delta), row.display, nf),
measure,
confidence: row.confidence,
detail: t(
row.mode === "nextDay"
? "insights.mood.crosstab.detailNextDay"
: "insights.mood.crosstab.detailSameDay",
{
withAvg: formatValue(row.withAvg, row.display, nf),
withoutAvg: formatValue(row.withoutAvg, row.display, nf),
unit,
withAvg: withUnit(
formatValue(abs(row.withAvg), row.display, nf),
unit,
),
withoutAvg: withUnit(
formatValue(abs(row.withoutAvg), row.display, nf),
unit,
),
withDays: row.withDays,
},
),
@@ -219,3 +219,65 @@ describe("no display source spells a word unit as a literal", () => {
).toEqual(["breaths/min"]);
});
});
/**
* A component that keeps its own table of unit words is a second source the
* shared one cannot correct: three insight boards each mapped their display
* kinds to `*.unit<Word>` bundle keys, and the German cycle board printed
* "S/min" where every other screen says "bpm". A unit beside a measurement
* value comes from `measurementUnitLabel`; an object literal holding two or
* more unit-word keys is refused.
*/
describe("no display source keeps its own unit table", () => {
const SRC = join(process.cwd(), "src");
const roots = ["app", "components", "hooks"];
const BLOCK = /=\s*\{([^{}]*)\}/g;
const UNIT_KEY = /:\s*"[\w.]+\.unit[A-Z]\w*"/g;
function unitTables(source: string): number[] {
const sizes: number[] = [];
for (const block of source.matchAll(BLOCK)) {
const n = [...block[1].matchAll(UNIT_KEY)].length;
if (n >= 2) sizes.push(n);
}
return sizes;
}
const offenders: string[] = [];
let scanned = 0;
for (const root of roots) {
const dir = join(SRC, root);
for (const rel of walkSourceFiles(dir, {
floor: root === "hooks" ? 50 : 500,
})) {
if (rel.includes("__tests__/") || rel.startsWith("api/")) continue;
scanned += 1;
const source = stripComments(readFileSync(join(dir, rel), "utf8"));
for (const size of unitTables(source)) {
offenders.push(`${root}/${rel}: a table of ${size} unit keys`);
}
}
}
it("reads the display tree", () => {
expect(scanned).toBeGreaterThan(1000);
});
it("finds none", () => {
expect(offenders).toEqual([]);
});
it("counter-check: would catch the boards' old table, not a single key", () => {
expect(
unitTables(`const UNIT_KEY: Record<D, string> = {
hours: "cycle.insights.crosstab.unitHours",
bpm: "cycle.insights.crosstab.unitBpm",
};`),
).toEqual([2]);
expect(
unitTables(
`const entry = { value: "SLEEP", unitKey: "measurements.unitHours" };`,
),
).toEqual([]);
});
});