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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-06 19:14:28 +02:00
1 parent b5da97cf6e
commit 73d0b0a3a4
8 files changed
+124 -3

No files matched your search

@@ -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(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
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("<CyclePhaseCrosstab>", () => {
// 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(
<CyclePhaseCrosstab
rows={[row("mood", "mood"), row("bloodGlucose", "glucose")]}
/>,
),
).not.toThrow();
});
it("resolves the mood + glucose metric labels and units", () => {
const html = render(
<CyclePhaseCrosstab
rows={[row("mood", "mood"), row("bloodGlucose", "glucose")]}
/>,
);
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(<CyclePhaseHeadline headline={row("mood", "mood")} />),
).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(<CyclePhaseCrosstab rows={[unknown]} />)).not.toThrow();
expect(() =>
render(<CyclePhaseHeadline headline={unknown} />),
).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(<CyclePhaseCrosstab rows={[unknown]} />);
expect(html).not.toContain("undefined");
});
});
+15 -3
View File
@@ -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<string, string> = {
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<CyclePhaseCrosstabDisplay, string> = {
@@ -59,6 +63,8 @@ const UNIT_KEY: Record<CyclePhaseCrosstabDisplay, string> = {
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<CyclePhaseCrosstabConfidence, string> = {
@@ -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