Merge pull request #277 from MBombeck/release/v1.15.2

v1.15.2 — cycle insights crash fix
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-06-06 19:22:40 +02:00
commit 9d68b07b9e
11 files changed
+132 -5

No files matched your search

+6
View File
@@ -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
+1 -1
View File
@@ -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.
+4
View File
@@ -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"
+4
View File
@@ -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"
+4
View File
@@ -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"
+4
View File
@@ -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"
+4
View File
@@ -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"
+4
View File
@@ -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ść"
+1 -1
View File
@@ -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",
@@ -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