mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
1 parent
5185edca74
commit
a37d45ab3f
18 files changed
+426
-337
No files matched your search
+9
-26
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user