mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
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:
1 parent
43be8ebd43
commit
0028290117
15 files changed
+292
-94
No files matched your search
+3
-7
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
|
||||
@@ -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")}
|
||||
/>
|
||||
|
||||
Reference in new issue
Block a user