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 1/2] 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 From d601c6c8603fb29ee8386a93d6dbeb8d4d3ff23a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 6 Jun 2026 19:16:05 +0200 Subject: [PATCH 2/2] =?UTF-8?q?chore(release):=20v1.15.2=20=E2=80=94=20cyc?= =?UTF-8?q?le=20insights=20crash=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7096e606..c57e682cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +## [1.15.2] — 2026-06-06 — cycle insights crash fix + +### Fixed + +- **Cycle insights no longer crash on mood or blood-glucose relations.** The insights tab could throw and fail to render when a mood or blood-glucose pattern cleared the significance check, because those two metrics were missing their on-screen labels and units. They now render correctly, and an unrecognised metric can never crash the tab again. + ## [1.15.1] — 2026-06-06 — cycle premium parity, reminder polish, fixes ### Added diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 2fef24a05..8d4fbc6e9 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.15.1 + version: 1.15.2 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index ea94a35c6..f14b91675 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.15.1", + "version": "1.15.2", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "AGPL-3.0-only", "homepage": "https://healthlog.dev",