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")} />