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