From 73d0b0a3a412b732269f54ae0487d9cfe6c1b896 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 6 Jun 2026 19:14:28 +0200 Subject: [PATCH] fix(cycle): render mood and glucose rows in the phase crosstab without crashing The phase-crosstab server emits display "mood" and "glucose", but the client unit/label maps only covered the vital channels, so a surviving mood or glucose phase correlation passed an undefined key to t() and crashed the insights tab. Add the missing maps and harden both unit lookups to fall back to an empty unit on any unmapped display. --- messages/de.json | 4 + messages/en.json | 4 + messages/es.json | 4 + messages/fr.json | 4 + messages/it.json | 4 + messages/pl.json | 4 + .../__tests__/cycle-phase-crosstab.test.tsx | 85 +++++++++++++++++++ src/components/cycle/cycle-phase-crosstab.tsx | 18 +++- 8 files changed, 124 insertions(+), 3 deletions(-) create mode 100644 src/components/cycle/__tests__/cycle-phase-crosstab.test.tsx diff --git a/messages/de.json b/messages/de.json index 950f0a9e2..ee1ad4f77 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4958,12 +4958,16 @@ "metricBasalBodyTemp": "Basaltemperatur", "metricWristTemperature": "Handgelenkstemperatur nachts", "metricSkinTemperature": "Hauttemperatur", + "metricBloodGlucose": "Blutzucker", + "metricMood": "Stimmung", "unitHours": "Std.", "unitSteps": "Schritte", "unitBpm": "S/min", "unitMs": "ms", "unitKg": "kg", "unitCelsius": "°C", + "unitGlucose": "mg/dL", + "unitMood": "Pkt.", "confidenceLow": "Geringe Sicherheit", "confidenceMedium": "Mittlere Sicherheit", "confidenceHigh": "Hohe Sicherheit" diff --git a/messages/en.json b/messages/en.json index c49172137..09f86e5f7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4958,12 +4958,16 @@ "metricBasalBodyTemp": "Basal body temperature", "metricWristTemperature": "Overnight wrist temperature", "metricSkinTemperature": "Skin temperature", + "metricBloodGlucose": "Blood glucose", + "metricMood": "Mood", "unitHours": "h", "unitSteps": "steps", "unitBpm": "bpm", "unitMs": "ms", "unitKg": "kg", "unitCelsius": "°C", + "unitGlucose": "mg/dL", + "unitMood": "pts", "confidenceLow": "Low confidence", "confidenceMedium": "Medium confidence", "confidenceHigh": "High confidence" diff --git a/messages/es.json b/messages/es.json index 0eb9a918f..f2dcad374 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4958,12 +4958,16 @@ "metricBasalBodyTemp": "Temperatura basal", "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", "unitKg": "kg", "unitCelsius": "°C", + "unitGlucose": "mg/dL", + "unitMood": "pts", "confidenceLow": "Confianza baja", "confidenceMedium": "Confianza media", "confidenceHigh": "Confianza alta" diff --git a/messages/fr.json b/messages/fr.json index 2ca665370..fbbd9cdb3 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4958,12 +4958,16 @@ "metricBasalBodyTemp": "Température basale", "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", "unitKg": "kg", "unitCelsius": "°C", + "unitGlucose": "mg/dL", + "unitMood": "pts", "confidenceLow": "Confiance faible", "confidenceMedium": "Confiance moyenne", "confidenceHigh": "Confiance élevée" diff --git a/messages/it.json b/messages/it.json index 81b1fe0ad..992a58d51 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4958,12 +4958,16 @@ "metricBasalBodyTemp": "Temperatura basale", "metricWristTemperature": "Temperatura del polso notturna", "metricSkinTemperature": "Temperatura della pelle", + "metricBloodGlucose": "Glicemia", + "metricMood": "Umore", "unitHours": "h", "unitSteps": "passi", "unitBpm": "bpm", "unitMs": "ms", "unitKg": "kg", "unitCelsius": "°C", + "unitGlucose": "mg/dL", + "unitMood": "pts", "confidenceLow": "Affidabilità bassa", "confidenceMedium": "Affidabilità media", "confidenceHigh": "Affidabilità alta" diff --git a/messages/pl.json b/messages/pl.json index 4b62a695e..54edaf4ba 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4958,12 +4958,16 @@ "metricBasalBodyTemp": "Temperatura podstawowa", "metricWristTemperature": "Nocna temperatura nadgarstka", "metricSkinTemperature": "Temperatura skóry", + "metricBloodGlucose": "Glukoza we krwi", + "metricMood": "Nastrój", "unitHours": "godz.", "unitSteps": "kroki", "unitBpm": "ud./min", "unitMs": "ms", "unitKg": "kg", "unitCelsius": "°C", + "unitGlucose": "mg/dL", + "unitMood": "pkt", "confidenceLow": "Niska pewność", "confidenceMedium": "Średnia pewność", "confidenceHigh": "Wysoka pewność" diff --git a/src/components/cycle/__tests__/cycle-phase-crosstab.test.tsx b/src/components/cycle/__tests__/cycle-phase-crosstab.test.tsx new file mode 100644 index 000000000..1691a7f67 --- /dev/null +++ b/src/components/cycle/__tests__/cycle-phase-crosstab.test.tsx @@ -0,0 +1,85 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { + CyclePhaseCrosstab, + CyclePhaseHeadline, + type CyclePhaseCrosstabRow, + type CyclePhaseCrosstabDisplay, +} from "../cycle-phase-crosstab"; +import { I18nProvider } from "@/lib/i18n/context"; + +function render(node: React.ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +function row( + metricKey: string, + display: CyclePhaseCrosstabDisplay, +): CyclePhaseCrosstabRow { + return { + metricKey, + display, + lutealDays: 12, + follicularDays: 11, + lutealAvg: 5, + follicularAvg: 4, + delta: 1, + pValue: 0.01, + qValue: 0.02, + confidence: "high", + }; +} + +describe("", () => { + // Regression: the server emits `display: "mood"` and `display: "glucose"`, + // which the client UNIT_KEY map did not cover. The map miss handed `undefined` + // to `t()`, whose resolver runs `key.split(".")` → "Cannot read properties of + // undefined (reading 'split')", crashing the whole insights tab. + it("renders mood and glucose rows without throwing", () => { + expect(() => + render( + , + ), + ).not.toThrow(); + }); + + it("resolves the mood + glucose metric labels and units", () => { + const html = render( + , + ); + expect(html).toContain("Mood"); + expect(html).toContain("Blood glucose"); + expect(html).toContain("pts"); + 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"); + }); + + it("renders the headline for a mood row without throwing", () => { + expect(() => + render(), + ).not.toThrow(); + }); + + it("degrades an unknown display to an empty unit instead of crashing", () => { + const unknown = row( + "somethingNew", + "unmappedDisplay" as unknown as CyclePhaseCrosstabDisplay, + ); + expect(() => render()).not.toThrow(); + expect(() => + render(), + ).not.toThrow(); + // The delta still renders; the unit segment is simply blank (trailing space + // trimmed away in markup), never the literal "undefined". + const html = render(); + expect(html).not.toContain("undefined"); + }); +}); diff --git a/src/components/cycle/cycle-phase-crosstab.tsx b/src/components/cycle/cycle-phase-crosstab.tsx index 74b5377ac..8c337d73c 100644 --- a/src/components/cycle/cycle-phase-crosstab.tsx +++ b/src/components/cycle/cycle-phase-crosstab.tsx @@ -24,7 +24,9 @@ export type CyclePhaseCrosstabDisplay = | "bpm" | "ms" | "kg" - | "celsius"; + | "celsius" + | "glucose" + | "mood"; export type CyclePhaseCrosstabConfidence = "low" | "medium" | "high"; @@ -50,6 +52,8 @@ const METRIC_LABEL_KEY: Record = { basalBodyTemp: "cycle.insights.crosstab.metricBasalBodyTemp", wristTemperature: "cycle.insights.crosstab.metricWristTemperature", skinTemperature: "cycle.insights.crosstab.metricSkinTemperature", + bloodGlucose: "cycle.insights.crosstab.metricBloodGlucose", + mood: "cycle.insights.crosstab.metricMood", }; const UNIT_KEY: Record = { @@ -59,6 +63,8 @@ const UNIT_KEY: Record = { ms: "cycle.insights.crosstab.unitMs", kg: "cycle.insights.crosstab.unitKg", celsius: "cycle.insights.crosstab.unitCelsius", + glucose: "cycle.insights.crosstab.unitGlucose", + mood: "cycle.insights.crosstab.unitMood", }; const CONFIDENCE_KEY: Record = { @@ -107,7 +113,10 @@ export function CyclePhaseHeadline({ const metricLabel = t( METRIC_LABEL_KEY[headline.metricKey] ?? headline.metricKey, ); - const unit = t(UNIT_KEY[headline.display]); + // Defensive: an unmapped display must never pass undefined to `t` (whose + // resolver does `key.split(".")`). An empty unit is the honest degrade. + const unitKey = UNIT_KEY[headline.display]; + const unit = unitKey ? t(unitKey) : ""; const up = headline.delta >= 0; const dir = t( up @@ -151,7 +160,10 @@ export function CyclePhaseCrosstab({ const metricLabel = t( METRIC_LABEL_KEY[row.metricKey] ?? row.metricKey, ); - const unit = t(UNIT_KEY[row.display]); + // Defensive: an unmapped display must never pass undefined to `t` + // (whose resolver does `key.split(".")`). Empty unit on a miss. + const unitKey = UNIT_KEY[row.display]; + const unit = unitKey ? t(unitKey) : ""; // `delta` = lutealAvg − follicularAvg: positive = the vital runs // higher in the luteal phase. The number stays NEUTRAL — higher is // good for steps but bad for resting HR, and this board is