fix(cycle): read basal temperature and the unit-bearing chart titles in the reader's units

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
This commit is contained in:
Marc-André Bombeck committed 2026-10-01 13:23:17 +02:00
1 parent 43be8ebd43
commit 0028290117
15 files changed
+292 -94

No files matched your search

+3 -7
View File
@@ -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": {
+3 -7
View File
@@ -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": {
+3 -7
View File
@@ -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": {
+3 -7
View File
@@ -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": {
+3 -7
View File
@@ -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": {
+3 -7
View File
@@ -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": {
+3 -7
View File
@@ -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": {
@@ -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),
};
}
@@ -87,7 +87,6 @@ const LEGIT_IDENTICAL = new Set<string>([
"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",
@@ -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("<BbtChart>", () => {
expect(html).toContain('data-slot="cycle-bbt-caption"');
});
});
describe("<BbtChart> — 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(
<BbtTooltip
active
payload={[
{
payload: {
t: Date.parse("2026-06-09T12:00:00Z"),
temp: 98.4,
phaseHue: "red",
mucus: null,
ovulationTest: null,
},
},
]}
/>,
);
expect(html).toContain("98.4 °F");
expect(html).not.toContain("°C");
} finally {
reader.preference = "metric";
}
});
});
@@ -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(),
}));
@@ -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);
});
});
+67 -28
View File
@@ -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<BbtPoint[]>(() => {
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))}
</p>
<p className="text-foreground font-medium tabular-nums">
{t("cycle.bbt.tooltipTemp", { temp: fmt.number(p.temp) })}
{t("cycle.bbt.tooltipTemp", {
temp: fmt.number(p.temp),
unit: unitDisplay.unitFor("BODY_TEMPERATURE"),
})}
</p>
{p.mucus ? (
<p className="text-muted-foreground mt-0.5">
@@ -55,14 +55,16 @@ const METRIC_LABEL_KEY: Record<string, string> = {
mood: "cycle.insights.crosstab.metricMood",
};
const UNIT_KEY: Record<CyclePhaseCrosstabDisplay, string> = {
/**
* 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<Record<CyclePhaseCrosstabDisplay, string>> = {
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",
};
+62 -10
View File
@@ -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<string, number | null>,
): 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<string>("");
// 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<OvulationTest | null>(null);
const [mucus, setMucus] = useState<CervicalMucus | null>(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")}
/>