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/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/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", 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