From 00282901173719531c4ecc230237758508695643 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 1 Oct 2026 13:12:06 +0200
Subject: [PATCH] fix(cycle): read basal temperature and the unit-bearing chart
titles in the reader's units
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The basal body temperature field, its chart and its tooltip were fixed
to degrees Celsius whatever the reader had chosen: the placeholder said
°C, the field accepted only 30-45, the chart plotted Celsius and the
tooltip printed "{temp} °C". Two chart titles carried a fixed unit as
well ("Wrist temperature (°C)", "Walking speed (km/h)") above an axis
already in the reader's unit.
The field now reads and writes in the reader's unit and stores Celsius;
a reading the person did not touch is saved back unchanged rather than
through a round trip of rounding. The chart plots, pads and labels in
the reader's unit, the two titles drop the unit the axis already
names, and the cycle contrast's fixed kg, °C and mg/dL labels, which
the transform had already replaced, leave the bundles.
Refs #1067
---
messages/de.json | 10 +-
messages/en.json | 10 +-
messages/es.json | 10 +-
messages/fr.json | 10 +-
messages/it.json | 10 +-
messages/ko.json | 10 +-
messages/pl.json | 10 +-
src/__tests__/helpers/unit-display-mock.ts | 42 ++++++++
src/__tests__/i18n-english-leak-guard.test.ts | 1 -
.../cycle/__tests__/bbt-chart.test.tsx | 60 +++++++++++-
.../__tests__/cycle-view-error-state.test.tsx | 8 ++
.../cycle/__tests__/log-day-sheet.test.ts | 28 ++++++
src/components/cycle/bbt-chart.tsx | 95 +++++++++++++------
src/components/cycle/cycle-phase-crosstab.tsx | 10 +-
src/components/cycle/log-day-sheet.tsx | 72 ++++++++++++--
15 files changed, 292 insertions(+), 94 deletions(-)
create mode 100644 src/__tests__/helpers/unit-display-mock.ts
diff --git a/messages/de.json b/messages/de.json
index 365f608ae..6b2f2bf51 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -3739,7 +3739,7 @@
"walkingSpeed": {
"title": "Gehgeschwindigkeit",
"description": "Deine durchschnittliche Gehgeschwindigkeit im Zeitverlauf, in km/h.",
- "chartTitle": "Gehgeschwindigkeit (km/h)",
+ "chartTitle": "Gehgeschwindigkeit",
"emptyState": {
"title": "Noch keine Gehgeschwindigkeitsdaten",
"description": "Werte erscheinen hier, sobald Apple Health synchronisiert."
@@ -4142,7 +4142,7 @@
"wristTemperature": {
"title": "Handgelenktemperatur",
"description": "Deine nächtliche Handgelenktemperatur, gemessen an deinem eigenen Ausgangswert.",
- "chartTitle": "Handgelenktemperatur (°C)",
+ "chartTitle": "Handgelenktemperatur",
"emptyState": {
"title": "Noch keine Handgelenktemperatur-Daten",
"description": "Werte erscheinen hier, sobald Apple Health das nächtliche Tragen synchronisiert."
@@ -8286,9 +8286,6 @@
"unitSteps": "Schritte",
"unitBpm": "S/min",
"unitMs": "ms",
- "unitKg": "kg",
- "unitCelsius": "°C",
- "unitGlucose": "mg/dL",
"unitMood": "Pkt.",
"confidenceLow": "Geringe Sicherheit",
"confidenceMedium": "Mittlere Sicherheit",
@@ -8463,7 +8460,6 @@
"flow": "Stärke",
"symptoms": "Symptome",
"temperature": "Basaltemperatur",
- "temperaturePlaceholder": "°C",
"temperatureDisturbed": "Messwert als gestört markieren",
"temperatureDisturbedHint": "Fieber, Krankheit, Alkohol oder eine späte Messung — wird von der Temperaturauswertung ausgeschlossen.",
"ovulationTest": "Ovulationstest",
@@ -8555,7 +8551,7 @@
"empty": "Noch keine Temperaturwerte",
"emptyHint": "Miss deine Basaltemperatur jeden Morgen, um den Temperaturanstieg deines Zyklus zu sehen.",
"ovulationEstimate": "Gesch. Eisprung",
- "tooltipTemp": "{temp} °C",
+ "tooltipTemp": "{temp} {unit}",
"ovulationConfirmed": "Bestätigter Eisprung"
},
"fieldInfo": {
diff --git a/messages/en.json b/messages/en.json
index e04b37a24..90e0d58ea 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -3739,7 +3739,7 @@
"walkingSpeed": {
"title": "Walking Speed",
"description": "Your average walking speed over time, in km/h.",
- "chartTitle": "Walking speed (km/h)",
+ "chartTitle": "Walking speed",
"emptyState": {
"title": "No walking-speed data yet",
"description": "Walking-speed readings appear here once Apple Health syncs."
@@ -4142,7 +4142,7 @@
"wristTemperature": {
"title": "Wrist Temperature",
"description": "Your overnight wrist temperature, tracked against your own baseline.",
- "chartTitle": "Wrist temperature (°C)",
+ "chartTitle": "Wrist temperature",
"emptyState": {
"title": "No wrist-temperature data yet",
"description": "Wrist-temperature readings appear here once Apple Health syncs overnight wear."
@@ -8286,9 +8286,6 @@
"unitSteps": "steps",
"unitBpm": "bpm",
"unitMs": "ms",
- "unitKg": "kg",
- "unitCelsius": "°C",
- "unitGlucose": "mg/dL",
"unitMood": "pts",
"confidenceLow": "Low confidence",
"confidenceMedium": "Medium confidence",
@@ -8463,7 +8460,6 @@
"flow": "Flow",
"symptoms": "Symptoms",
"temperature": "Basal body temperature",
- "temperaturePlaceholder": "°C",
"temperatureDisturbed": "Mark reading as disturbed",
"temperatureDisturbedHint": "Fever, illness, alcohol or a late measurement — excluded from the temperature evaluation.",
"ovulationTest": "Ovulation test",
@@ -8555,7 +8551,7 @@
"empty": "No temperature readings yet",
"emptyHint": "Log your basal body temperature each morning to see your cycle's temperature shift.",
"ovulationEstimate": "Est. ovulation",
- "tooltipTemp": "{temp} °C",
+ "tooltipTemp": "{temp} {unit}",
"ovulationConfirmed": "Confirmed ovulation"
},
"fieldInfo": {
diff --git a/messages/es.json b/messages/es.json
index 97027af1f..8e850ae00 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -3739,7 +3739,7 @@
"walkingSpeed": {
"title": "Velocidad al andar",
"description": "Tu velocidad media al andar en el tiempo, en km/h.",
- "chartTitle": "Velocidad al andar (km/h)",
+ "chartTitle": "Velocidad al andar",
"emptyState": {
"title": "Aún no hay datos",
"description": "Las lecturas aparecen aquí cuando Apple Health sincroniza."
@@ -4142,7 +4142,7 @@
"wristTemperature": {
"title": "Temperatura de muñeca",
"description": "Tu temperatura de muñeca durante la noche, comparada con tu valor de referencia.",
- "chartTitle": "Temperatura de muñeca (°C)",
+ "chartTitle": "Temperatura de muñeca",
"emptyState": {
"title": "Aún no hay datos de temperatura de muñeca",
"description": "Los valores aparecen aquí cuando Apple Health sincroniza el uso nocturno."
@@ -8286,9 +8286,6 @@
"unitSteps": "pasos",
"unitBpm": "lpm",
"unitMs": "ms",
- "unitKg": "kg",
- "unitCelsius": "°C",
- "unitGlucose": "mg/dL",
"unitMood": "pts",
"confidenceLow": "Confianza baja",
"confidenceMedium": "Confianza media",
@@ -8463,7 +8460,6 @@
"flow": "Flujo",
"symptoms": "Síntomas",
"temperature": "Temperatura basal",
- "temperaturePlaceholder": "°C",
"temperatureDisturbed": "Marcar la medición como alterada",
"temperatureDisturbedHint": "Fiebre, enfermedad, alcohol o una medición tardía: se excluye de la evaluación de temperatura.",
"ovulationTest": "Test de ovulación",
@@ -8555,7 +8551,7 @@
"empty": "Aún no hay mediciones de temperatura",
"emptyHint": "Registra tu temperatura basal cada mañana para ver el cambio de temperatura de tu ciclo.",
"ovulationEstimate": "Ovulación est.",
- "tooltipTemp": "{temp} °C",
+ "tooltipTemp": "{temp} {unit}",
"ovulationConfirmed": "Ovulación confirmada"
},
"fieldInfo": {
diff --git a/messages/fr.json b/messages/fr.json
index ffb3f280a..7cf020417 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -3739,7 +3739,7 @@
"walkingSpeed": {
"title": "Vitesse de marche",
"description": "Votre vitesse de marche moyenne au fil du temps, en km/h.",
- "chartTitle": "Vitesse de marche (km/h)",
+ "chartTitle": "Vitesse de marche",
"emptyState": {
"title": "Aucune donnée",
"description": "Les relevés apparaissent ici dès qu’Apple Health se synchronise."
@@ -4142,7 +4142,7 @@
"wristTemperature": {
"title": "Température du poignet",
"description": "Votre température du poignet pendant la nuit, comparée à votre valeur de référence.",
- "chartTitle": "Température du poignet (°C)",
+ "chartTitle": "Température du poignet",
"emptyState": {
"title": "Pas encore de données de température du poignet",
"description": "Les valeurs apparaissent ici dès qu'Apple Santé synchronise le port nocturne."
@@ -8286,9 +8286,6 @@
"unitSteps": "pas",
"unitBpm": "bpm",
"unitMs": "ms",
- "unitKg": "kg",
- "unitCelsius": "°C",
- "unitGlucose": "mg/dL",
"unitMood": "pts",
"confidenceLow": "Confiance faible",
"confidenceMedium": "Confiance moyenne",
@@ -8463,7 +8460,6 @@
"flow": "Flux",
"symptoms": "Symptômes",
"temperature": "Température basale",
- "temperaturePlaceholder": "°C",
"temperatureDisturbed": "Marquer la mesure comme perturbée",
"temperatureDisturbedHint": "Fièvre, maladie, alcool ou mesure tardive — exclue de l'évaluation de la température.",
"ovulationTest": "Test d'ovulation",
@@ -8555,7 +8551,7 @@
"empty": "Pas encore de relevés de température",
"emptyHint": "Notez votre température basale chaque matin pour voir le décalage de température de votre cycle.",
"ovulationEstimate": "Ovulation est.",
- "tooltipTemp": "{temp} °C",
+ "tooltipTemp": "{temp} {unit}",
"ovulationConfirmed": "Ovulation confirmée"
},
"fieldInfo": {
diff --git a/messages/it.json b/messages/it.json
index 66e7ee9db..83b14de00 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -3739,7 +3739,7 @@
"walkingSpeed": {
"title": "Velocità di cammino",
"description": "La tua velocità media di cammino nel tempo, in km/h.",
- "chartTitle": "Velocità di cammino (km/h)",
+ "chartTitle": "Velocità di cammino",
"emptyState": {
"title": "Nessun dato",
"description": "Le letture appaiono qui quando Apple Health si sincronizza."
@@ -4142,7 +4142,7 @@
"wristTemperature": {
"title": "Temperatura del polso",
"description": "La temperatura del polso durante la notte, rispetto al tuo valore di riferimento.",
- "chartTitle": "Temperatura del polso (°C)",
+ "chartTitle": "Temperatura del polso",
"emptyState": {
"title": "Nessun dato sulla temperatura del polso",
"description": "I valori compaiono qui quando Apple Health sincronizza l'uso notturno."
@@ -8286,9 +8286,6 @@
"unitSteps": "passi",
"unitBpm": "bpm",
"unitMs": "ms",
- "unitKg": "kg",
- "unitCelsius": "°C",
- "unitGlucose": "mg/dL",
"unitMood": "pts",
"confidenceLow": "Affidabilità bassa",
"confidenceMedium": "Affidabilità media",
@@ -8463,7 +8460,6 @@
"flow": "Flusso",
"symptoms": "Sintomi",
"temperature": "Temperatura basale",
- "temperaturePlaceholder": "°C",
"temperatureDisturbed": "Segna la misurazione come disturbata",
"temperatureDisturbedHint": "Febbre, malattia, alcol o una misurazione tardiva: esclusa dalla valutazione della temperatura.",
"ovulationTest": "Test di ovulazione",
@@ -8555,7 +8551,7 @@
"empty": "Ancora nessuna misurazione della temperatura",
"emptyHint": "Registra la temperatura basale ogni mattina per vedere il rialzo termico del tuo ciclo.",
"ovulationEstimate": "Ovulazione stim.",
- "tooltipTemp": "{temp} °C",
+ "tooltipTemp": "{temp} {unit}",
"ovulationConfirmed": "Ovulazione confermata"
},
"fieldInfo": {
diff --git a/messages/ko.json b/messages/ko.json
index fd7eb9f96..bb57177ef 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -3739,7 +3739,7 @@
"walkingSpeed": {
"title": "보행 속도",
"description": "시간에 따른 평균 보행 속도예요(km/h).",
- "chartTitle": "보행 속도(km/h)",
+ "chartTitle": "보행 속도",
"emptyState": {
"title": "아직 보행 속도 데이터가 없어요",
"description": "Apple Health가 동기화되면 보행 속도 측정값이 여기에 나타나요."
@@ -4142,7 +4142,7 @@
"wristTemperature": {
"title": "손목 온도",
"description": "내 기준값과 비교한 밤사이 손목 온도예요.",
- "chartTitle": "손목 온도 (°C)",
+ "chartTitle": "손목 온도",
"emptyState": {
"title": "아직 손목 온도 데이터가 없어요",
"description": "Apple Health가 밤사이 착용 기록을 동기화하면 손목 온도 측정값이 여기에 표시돼요."
@@ -8286,9 +8286,6 @@
"unitSteps": "걸음",
"unitBpm": "bpm",
"unitMs": "ms",
- "unitKg": "kg",
- "unitCelsius": "°C",
- "unitGlucose": "mg/dL",
"unitMood": "점",
"confidenceLow": "신뢰도 낮음",
"confidenceMedium": "신뢰도 보통",
@@ -8463,7 +8460,6 @@
"flow": "생리량",
"symptoms": "증상",
"temperature": "기초 체온",
- "temperaturePlaceholder": "°C",
"temperatureDisturbed": "측정값을 방해받음으로 표시",
"temperatureDisturbedHint": "발열, 아픈 날, 음주, 늦은 측정 — 체온 평가에서 제외돼요.",
"ovulationTest": "배란 테스트",
@@ -8555,7 +8551,7 @@
"empty": "아직 체온 기록이 없어요",
"emptyHint": "매일 아침 기초 체온을 기록하면 주기의 체온 변화를 볼 수 있어요.",
"ovulationEstimate": "예상 배란",
- "tooltipTemp": "{temp}°C",
+ "tooltipTemp": "{temp}{unit}",
"ovulationConfirmed": "확인된 배란"
},
"fieldInfo": {
diff --git a/messages/pl.json b/messages/pl.json
index 122c19159..90fcc23d4 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -3739,7 +3739,7 @@
"walkingSpeed": {
"title": "Prędkość chodu",
"description": "Twoja średnia prędkość chodu w czasie, w km/h.",
- "chartTitle": "Prędkość chodu (km/h)",
+ "chartTitle": "Prędkość chodu",
"emptyState": {
"title": "Brak danych",
"description": "Odczyty pojawią się tutaj po synchronizacji Apple Health."
@@ -4142,7 +4142,7 @@
"wristTemperature": {
"title": "Temperatura nadgarstka",
"description": "Nocna temperatura nadgarstka, odniesiona do Twojej wartości bazowej.",
- "chartTitle": "Temperatura nadgarstka (°C)",
+ "chartTitle": "Temperatura nadgarstka",
"emptyState": {
"title": "Brak danych o temperaturze nadgarstka",
"description": "Wartości pojawią się tutaj, gdy Apple Health zsynchronizuje nocne noszenie."
@@ -8286,9 +8286,6 @@
"unitSteps": "kroki",
"unitBpm": "ud./min",
"unitMs": "ms",
- "unitKg": "kg",
- "unitCelsius": "°C",
- "unitGlucose": "mg/dL",
"unitMood": "pkt",
"confidenceLow": "Niska pewność",
"confidenceMedium": "Średnia pewność",
@@ -8463,7 +8460,6 @@
"flow": "Krwawienie",
"symptoms": "Objawy",
"temperature": "Podstawowa temperatura ciała",
- "temperaturePlaceholder": "°C",
"temperatureDisturbed": "Oznacz pomiar jako zakłócony",
"temperatureDisturbedHint": "Gorączka, choroba, alkohol lub spóźniony pomiar — wykluczony z oceny temperatury.",
"ovulationTest": "Test owulacyjny",
@@ -8555,7 +8551,7 @@
"empty": "Brak pomiarów temperatury",
"emptyHint": "Zapisuj podstawową temperaturę ciała każdego ranka, aby zobaczyć przesunięcie temperatury w cyklu.",
"ovulationEstimate": "Szac. owulacja",
- "tooltipTemp": "{temp} °C",
+ "tooltipTemp": "{temp} {unit}",
"ovulationConfirmed": "Potwierdzona owulacja"
},
"fieldInfo": {
diff --git a/src/__tests__/helpers/unit-display-mock.ts b/src/__tests__/helpers/unit-display-mock.ts
new file mode 100644
index 000000000..60a90a914
--- /dev/null
+++ b/src/__tests__/helpers/unit-display-mock.ts
@@ -0,0 +1,42 @@
+/**
+ * A real `UnitDisplay` for one fixed preference, for component tests that
+ * render without a QueryClient (`useUnitDisplay` reads the account through
+ * react-query). Built on the production transforms, so a test asserts the
+ * same numbers and symbols the hook would give.
+ *
+ * vi.mock("@/hooks/use-unit-display", async () => {
+ * const { unitDisplayFor } = await import("@/__tests__/helpers/unit-display-mock");
+ * return { useUnitDisplay: () => unitDisplayFor("metric") };
+ * });
+ */
+import type { UnitDisplay } from "@/hooks/use-unit-display";
+import type { GlucoseUnit } from "@/lib/glucose";
+import {
+ applyDisplayTransform,
+ applyDisplayTransformDelta,
+ getDisplayTransform,
+ hasDisplayTransform,
+ invertDisplayTransform,
+ type UnitPreference,
+} from "@/lib/measurements/display-transform";
+
+export function unitDisplayFor(
+ preference: UnitPreference,
+ glucoseUnit: GlucoseUnit = "mg/dL",
+): UnitDisplay {
+ // The hook resolves mass, length and temperature through the
+ // metric/imperial branch alone; glucose is converted where it renders.
+ const forType = (type: string) => getDisplayTransform(type, preference);
+ return {
+ preference,
+ glucoseUnit,
+ transformFor: forType,
+ toDisplay: (type, value) => applyDisplayTransform(value, forType(type)),
+ fromDisplay: (type, value) => invertDisplayTransform(value, forType(type)),
+ toDisplayDelta: (type, value) =>
+ applyDisplayTransformDelta(value, forType(type)),
+ unitFor: (type) => forType(type).displayUnit,
+ decimalsFor: (type) => forType(type).decimals,
+ isTransformed: (type) => hasDisplayTransform(type),
+ };
+}
diff --git a/src/__tests__/i18n-english-leak-guard.test.ts b/src/__tests__/i18n-english-leak-guard.test.ts
index dc9d79fac..897cd03bb 100644
--- a/src/__tests__/i18n-english-leak-guard.test.ts
+++ b/src/__tests__/i18n-english-leak-guard.test.ts
@@ -87,7 +87,6 @@ const LEGIT_IDENTICAL = new Set([
"common.period",
// Clinical abbreviations / SI units — identical across locales.
"charts.vo2Max",
- "cycle.insights.crosstab.unitGlucose",
"dashboard.metric.unit.bmi",
"dashboard.metric.unit.mood",
"dashboard.metric.unit.glucose",
diff --git a/src/components/cycle/__tests__/bbt-chart.test.tsx b/src/components/cycle/__tests__/bbt-chart.test.tsx
index e2adcb629..52c9fc140 100644
--- a/src/components/cycle/__tests__/bbt-chart.test.tsx
+++ b/src/components/cycle/__tests__/bbt-chart.test.tsx
@@ -1,7 +1,20 @@
-import { describe, it, expect } from "vitest";
+import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
-import { BbtChart } from "../bbt-chart";
+// `useUnitDisplay` reads the account through react-query; these static
+// renders have no QueryClient, so the hook is a real display for a fixed
+// preference the test sets.
+const reader = vi.hoisted(() => ({
+ preference: "metric" as "metric" | "imperial",
+}));
+vi.mock("@/hooks/use-unit-display", async () => {
+ const { unitDisplayFor } =
+ await import("@/__tests__/helpers/unit-display-mock");
+ return { useUnitDisplay: () => unitDisplayFor(reader.preference) };
+});
+
+import { BbtChart, BbtTooltip, bbtPoints } from "../bbt-chart";
+import { unitDisplayFor } from "@/__tests__/helpers/unit-display-mock";
import { I18nProvider } from "@/lib/i18n/context";
import type { CalendarDay } from "../types";
@@ -82,3 +95,46 @@ describe("", () => {
expect(html).toContain('data-slot="cycle-bbt-caption"');
});
});
+
+describe(" — the reader's temperature unit", () => {
+ it("plots each reading in °F for an imperial reader", () => {
+ const display = unitDisplayFor("imperial");
+ const points = bbtPoints({
+ days: [
+ day("2026-06-05", { basalBodyTempC: 36.5 }),
+ day("2026-06-09", { basalBodyTempC: 36.9 }),
+ ],
+ fromDate: "2026-06-01",
+ today: "2026-06-10",
+ rawChartMode: false,
+ toDisplay: (c) => display.toDisplay("BODY_TEMPERATURE", c),
+ });
+ expect(points.map((p) => p.temp)).toEqual([97.7, 98.4]);
+ });
+
+ it("labels the tooltip with the reader's unit", () => {
+ reader.preference = "imperial";
+ try {
+ const html = render(
+ ,
+ );
+ expect(html).toContain("98.4 °F");
+ expect(html).not.toContain("°C");
+ } finally {
+ reader.preference = "metric";
+ }
+ });
+});
diff --git a/src/components/cycle/__tests__/cycle-view-error-state.test.tsx b/src/components/cycle/__tests__/cycle-view-error-state.test.tsx
index ece977814..cedad4149 100644
--- a/src/components/cycle/__tests__/cycle-view-error-state.test.tsx
+++ b/src/components/cycle/__tests__/cycle-view-error-state.test.tsx
@@ -7,6 +7,14 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
+// The BBT chart reads the unit preference through react-query; no
+// QueryClient here, so a fixed metric display stands in.
+vi.mock("@/hooks/use-unit-display", async () => {
+ const { unitDisplayFor } =
+ await import("@/__tests__/helpers/unit-display-mock");
+ return { useUnitDisplay: () => unitDisplayFor("metric") };
+});
+
vi.mock("next/navigation", () => ({
useSearchParams: () => new URLSearchParams(),
}));
diff --git a/src/components/cycle/__tests__/log-day-sheet.test.ts b/src/components/cycle/__tests__/log-day-sheet.test.ts
index 78e280933..85bb575f5 100644
--- a/src/components/cycle/__tests__/log-day-sheet.test.ts
+++ b/src/components/cycle/__tests__/log-day-sheet.test.ts
@@ -3,6 +3,7 @@ import { describe, it, expect } from "vitest";
import {
buildDayLogPatch,
buildDayLogInput,
+ bbtTextToCelsius,
symptomsCount,
temperatureCount,
intimacyCount,
@@ -330,3 +331,30 @@ describe("sheetDayContext (#1004)", () => {
expect(ctx.periodEndable).toBe(true);
});
});
+
+describe("bbtTextToCelsius — the field reads in the reader's unit", () => {
+ const fromFahrenheit = (f: number) => (f - 32) / 1.8;
+
+ it("stores a typed Fahrenheit reading as Celsius", () => {
+ expect(bbtTextToCelsius("98.2", null, fromFahrenheit)).toBe(36.78);
+ });
+
+ it("returns the stored value for text the sheet seeded, so a save never drifts it", () => {
+ const seed = { text: "98.2", celsius: 36.75 };
+ expect(bbtTextToCelsius("98.2", seed, fromFahrenheit)).toBe(36.75);
+ expect(bbtTextToCelsius("98.4", seed, fromFahrenheit)).toBe(36.89);
+ });
+
+ it("is null for an empty or unreadable field", () => {
+ expect(bbtTextToCelsius("", null, fromFahrenheit)).toBeNull();
+ expect(bbtTextToCelsius("abc", null, fromFahrenheit)).toBeNull();
+ });
+
+ it("feeds the payload through the converter it is given", () => {
+ const patch = buildDayLogPatch({ ...blank(), bbt: "98.2" }, (text) =>
+ bbtTextToCelsius(text, null, fromFahrenheit),
+ );
+ expect(patch.basalBodyTempC).toBe(36.78);
+ expect(patch.temperatureExcluded).toBe(false);
+ });
+});
diff --git a/src/components/cycle/bbt-chart.tsx b/src/components/cycle/bbt-chart.tsx
index 75471ce7d..30416c4fb 100644
--- a/src/components/cycle/bbt-chart.tsx
+++ b/src/components/cycle/bbt-chart.tsx
@@ -40,6 +40,7 @@ import {
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
+import { useUnitDisplay } from "@/hooks/use-unit-display";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import { cn } from "@/lib/utils";
import type { CalendarDay, CervicalMucus, OvulationTest } from "./types";
@@ -81,6 +82,39 @@ function ymdToMs(d: string): number {
return Date.parse(`${d}T12:00:00Z`);
}
+/**
+ * The plotted readings of the window, oldest first, each temperature in the
+ * reader's unit (`toDisplay` converts the stored degrees Celsius).
+ */
+export function bbtPoints(args: {
+ days: CalendarDay[];
+ fromDate: string;
+ today: string;
+ rawChartMode: boolean;
+ toDisplay: (celsius: number) => number;
+}): BbtPoint[] {
+ const { days, fromDate, today, rawChartMode, toDisplay } = args;
+ const fromMs = ymdToMs(fromDate);
+ const todayMs = ymdToMs(today);
+ return days
+ .filter((d) => {
+ if (d.basalBodyTempC == null || !Number.isFinite(d.basalBodyTempC)) {
+ return false;
+ }
+ const ms = ymdToMs(d.date);
+ return ms >= fromMs && ms <= todayMs;
+ })
+ .map((d) => ({
+ t: ymdToMs(d.date),
+ temp: toDisplay(d.basalBodyTempC as number),
+ phaseHue:
+ rawChartMode || d.phase == null ? LINE_COLOR : PHASE_HUE[d.phase],
+ mucus: d.cervicalMucus,
+ ovulationTest: d.ovulationTest,
+ }))
+ .sort((a, b) => a.t - b.t);
+}
+
export function BbtChart({
days,
today,
@@ -91,6 +125,9 @@ export function BbtChart({
}: BbtChartProps) {
const { t } = useTranslations();
const fmt = useFormatters();
+ // Stored in °C, read in the reader's unit: every point, the padding and
+ // the empty-state domain go through the same transform.
+ const unitDisplay = useUnitDisplay();
const animationsEnabled = !prefersReducedMotion();
// Scope to the current cycle using the server's resolved start; fall back to
@@ -101,27 +138,18 @@ export function BbtChart({
return shiftDateKey(today, -FALLBACK_WINDOW_DAYS);
}, [cycleStartDate, today]);
- const points = useMemo(() => {
- const fromMs = ymdToMs(fromDate);
- const todayMs = ymdToMs(today);
- return days
- .filter((d) => {
- if (d.basalBodyTempC == null || !Number.isFinite(d.basalBodyTempC)) {
- return false;
- }
- const ms = ymdToMs(d.date);
- return ms >= fromMs && ms <= todayMs;
- })
- .map((d) => ({
- t: ymdToMs(d.date),
- temp: d.basalBodyTempC as number,
- phaseHue:
- rawChartMode || d.phase == null ? LINE_COLOR : PHASE_HUE[d.phase],
- mucus: d.cervicalMucus,
- ovulationTest: d.ovulationTest,
- }))
- .sort((a, b) => a.t - b.t);
- }, [days, fromDate, today, rawChartMode]);
+ const points = useMemo(
+ () =>
+ bbtPoints({
+ days,
+ fromDate,
+ today,
+ rawChartMode,
+ toDisplay: (celsius) =>
+ unitDisplay.toDisplay("BODY_TEMPERATURE", celsius),
+ }),
+ [days, fromDate, today, rawChartMode, unitDisplay],
+ );
const ovulationMs = useMemo(() => {
if (rawChartMode || !predictedOvulation) return null;
@@ -133,16 +161,23 @@ export function BbtChart({
}, [predictedOvulation, points, rawChartMode]);
const yDomain = useMemo<[number, number]>(() => {
- if (points.length === 0) return [36, 37.5];
+ if (points.length === 0) {
+ return [
+ unitDisplay.toDisplay("BODY_TEMPERATURE", 36),
+ unitDisplay.toDisplay("BODY_TEMPERATURE", 37.5),
+ ];
+ }
const temps = points.map((p) => p.temp);
const min = Math.min(...temps);
const max = Math.max(...temps);
- // Pad ±0.15 °C so the shift is legible and the line never hugs an edge.
+ // Pad by 0.15 °C (in the reader's unit) so the shift is legible and the
+ // line never hugs an edge.
+ const pad = unitDisplay.toDisplayDelta("BODY_TEMPERATURE", 0.15);
return [
- Math.floor((min - 0.15) * 10) / 10,
- Math.ceil((max + 0.15) * 10) / 10,
+ Math.floor((min - pad) * 10) / 10,
+ Math.ceil((max + pad) * 10) / 10,
];
- }, [points]);
+ }, [points, unitDisplay]);
const ticks = useMemo(() => {
if (points.length < 2) return [];
@@ -279,12 +314,13 @@ function PhaseDot(props: { cx?: number; cy?: number; payload?: BbtPoint }) {
}
/** Tooltip restating the temperature + any fertility signs logged that day. */
-function BbtTooltip(props: {
+export function BbtTooltip(props: {
active?: boolean;
payload?: { payload: BbtPoint }[];
}) {
const { t } = useTranslations();
const fmt = useFormatters();
+ const unitDisplay = useUnitDisplay();
if (!props.active || !props.payload || props.payload.length === 0)
return null;
const p = props.payload[0].payload;
@@ -298,7 +334,10 @@ function BbtTooltip(props: {
{fmt.dateShortSmart(new Date(p.t))}
- {t("cycle.bbt.tooltipTemp", { temp: fmt.number(p.temp) })}
+ {t("cycle.bbt.tooltipTemp", {
+ temp: fmt.number(p.temp),
+ unit: unitDisplay.unitFor("BODY_TEMPERATURE"),
+ })}
{p.mucus ? (
diff --git a/src/components/cycle/cycle-phase-crosstab.tsx b/src/components/cycle/cycle-phase-crosstab.tsx
index 0a9fe400d..9dd962d90 100644
--- a/src/components/cycle/cycle-phase-crosstab.tsx
+++ b/src/components/cycle/cycle-phase-crosstab.tsx
@@ -55,14 +55,16 @@ const METRIC_LABEL_KEY: Record = {
mood: "cycle.insights.crosstab.metricMood",
};
-const UNIT_KEY: Record = {
+/**
+ * Bundle labels for the displays no preference changes. Weight, the
+ * temperatures and glucose take their symbol from the reader's transform
+ * (`DISPLAY_TO_TYPE` below) and have no bundle label to fall back on.
+ */
+const UNIT_KEY: Partial> = {
hours: "cycle.insights.crosstab.unitHours",
steps: "cycle.insights.crosstab.unitSteps",
bpm: "cycle.insights.crosstab.unitBpm",
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",
};
diff --git a/src/components/cycle/log-day-sheet.tsx b/src/components/cycle/log-day-sheet.tsx
index 8d7c5411f..3cf04f61d 100644
--- a/src/components/cycle/log-day-sheet.tsx
+++ b/src/components/cycle/log-day-sheet.tsx
@@ -45,6 +45,7 @@ import {
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { SheetSection, SheetSectionCount } from "@/components/ui/sheet-section";
import { useTranslations } from "@/lib/i18n/context";
+import { useUnitDisplay } from "@/hooks/use-unit-display";
import { resolveIntlLocale } from "@/lib/format-locale";
import type { Locale } from "@/lib/i18n/config";
import { CUSTOM_SYMPTOM_ICON_ALLOWLIST } from "@/lib/cycle/custom-symptoms-shared";
@@ -188,6 +189,23 @@ function resolveBbt(bbt: string): number | null {
return n != null && Number.isFinite(n) ? n : null;
}
+/**
+ * The temperature field's text, typed in the reader's unit, as the stored
+ * degrees Celsius (two decimals, the field's own step). Text equal to what
+ * the sheet seeded returns the stored value untouched: converting it there
+ * and back would move a reading the person never edited.
+ */
+export function bbtTextToCelsius(
+ text: string,
+ seed: { text: string; celsius: number } | null,
+ toCelsius: (typed: number) => number,
+): number | null {
+ const typed = resolveBbt(text);
+ if (typed == null) return null;
+ if (seed && text === seed.text) return seed.celsius;
+ return Math.round(toCelsius(typed) * 100) / 100;
+}
+
function symptomList(
symptoms: Map,
): CycleSymptomSelection[] {
@@ -202,13 +220,22 @@ function symptomList(
* deselected so the server clears it (the POST merge can only add/keep — QA
* W-2). Pure + exported for the unit test.
*/
-export function buildDayLogPatch(s: DayLogFormState): CycleDayLogPatch {
+export function buildDayLogPatch(
+ s: DayLogFormState,
+ /**
+ * The typed temperature (in the reader's unit) as stored degrees Celsius.
+ * The sheet passes the preference-bound conversion; the default reads the
+ * field as Celsius.
+ */
+ bbtToCelsius: (bbt: string) => number | null = resolveBbt,
+): CycleDayLogPatch {
+ const bbtC = bbtToCelsius(s.bbt);
return {
flow: s.flow ?? null,
intermenstrualBleeding: s.intermenstrual,
- basalBodyTempC: resolveBbt(s.bbt),
+ basalBodyTempC: bbtC,
// Only meaningful with a BBT present; a cleared reading resets the flag.
- temperatureExcluded: resolveBbt(s.bbt) != null ? s.bbtDisturbed : false,
+ temperatureExcluded: bbtC != null ? s.bbtDisturbed : false,
ovulationTest: s.opk ?? null,
cervicalMucus: s.mucus ?? null,
cervixPosition: s.cervixPosition ?? null,
@@ -229,8 +256,10 @@ export function buildDayLogPatch(s: DayLogFormState): CycleDayLogPatch {
export function buildDayLogInput(
s: DayLogFormState,
date: string,
+ /** See `buildDayLogPatch`. */
+ bbtToCelsius: (bbt: string) => number | null = resolveBbt,
): CycleDayLogInput {
- const bbtVal = resolveBbt(s.bbt);
+ const bbtVal = bbtToCelsius(s.bbt);
return {
date,
loggedAt: new Date().toISOString(),
@@ -619,6 +648,18 @@ export function LogDaySheet({
new Map(),
);
const [bbt, setBbt] = useState("");
+ // The temperature field reads and writes in the reader's unit; storage is
+ // degrees Celsius. The seeded text keeps its stored value, so saving an
+ // untouched reading never drifts it through a round trip of rounding.
+ const unitDisplay = useUnitDisplay();
+ const [bbtSeed, setBbtSeed] = useState<{
+ text: string;
+ celsius: number;
+ } | null>(null);
+ const bbtToCelsius = (text: string): number | null =>
+ bbtTextToCelsius(text, bbtSeed, (typed) =>
+ unitDisplay.fromDisplay("BODY_TEMPERATURE", typed),
+ );
const [bbtDisturbed, setBbtDisturbed] = useState(false);
const [opk, setOpk] = useState(null);
const [mucus, setMucus] = useState(null);
@@ -666,7 +707,16 @@ export function LogDaySheet({
setFlow((dto?.flow ?? null) as FlowLevel | null);
setIntermenstrual(dto?.intermenstrualBleeding ?? false);
setSymptoms(new Map((dto?.symptoms ?? []).map((s) => [s.key, s.severity])));
- setBbt(dto?.basalBodyTempC != null ? String(dto.basalBodyTempC) : "");
+ const seededBbt =
+ dto?.basalBodyTempC != null
+ ? String(unitDisplay.toDisplay("BODY_TEMPERATURE", dto.basalBodyTempC))
+ : "";
+ setBbt(seededBbt);
+ setBbtSeed(
+ dto?.basalBodyTempC != null
+ ? { text: seededBbt, celsius: dto.basalBodyTempC }
+ : null,
+ );
setBbtDisturbed(dto?.temperatureExcluded ?? false);
setOpk((dto?.ovulationTest ?? null) as OvulationTest | null);
setMucus((dto?.cervicalMucus ?? null) as CervicalMucus | null);
@@ -753,10 +803,12 @@ export function LogDaySheet({
// chip actually CLEARS (the POST merge can only add/keep — QA W-2).
await patchDay.mutateAsync({
id: currentRowId,
- patch: buildDayLogPatch(formState),
+ patch: buildDayLogPatch(formState, bbtToCelsius),
});
} else {
- await logDay.mutateAsync(buildDayLogInput(formState, date));
+ await logDay.mutateAsync(
+ buildDayLogInput(formState, date, bbtToCelsius),
+ );
}
onOpenChange(false);
} catch {
@@ -1041,11 +1093,11 @@ export function LogDaySheet({
type="number"
inputMode="decimal"
step="0.01"
- min={30}
- max={45}
+ min={unitDisplay.toDisplay("BODY_TEMPERATURE", 30)}
+ max={unitDisplay.toDisplay("BODY_TEMPERATURE", 45)}
value={bbt}
onChange={(e) => setBbt(e.target.value)}
- placeholder={t("cycle.sheet.temperaturePlaceholder")}
+ placeholder={unitDisplay.unitFor("BODY_TEMPERATURE")}
className="w-32"
aria-label={t("cycle.sheet.temperature")}
/>