mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
Merge pull request #1069 from MBombeck/fix/v1397-units
One unit system: every surface follows the reader's glucose and metric/imperial preference
This commit is contained in:
74 files changed
+2304
-292
No files matched your search
@@ -11,6 +11,11 @@ import {
|
||||
} from "recharts";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useUnitDisplay } from "@/hooks/use-unit-display";
|
||||
import {
|
||||
applyDisplayTransform,
|
||||
getQuantityTransform,
|
||||
} from "@/lib/measurements/display-transform";
|
||||
|
||||
/**
|
||||
* Workout elevation profile — a small cumulative-distance / altitude
|
||||
@@ -30,7 +35,15 @@ export function WorkoutElevationChart({
|
||||
points: WorkoutElevationPoint[];
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const data = points.map((p) => ({ km: p.distanceM / 1000, alt: p.altitude }));
|
||||
const { preference } = useUnitDisplay();
|
||||
// Distance along the route in km or mi, altitude in m or ft — the same
|
||||
// registry the stat tiles read, so the axis and the tiles agree.
|
||||
const distance = getQuantityTransform("distance", preference);
|
||||
const elevation = getQuantityTransform("elevation", preference);
|
||||
const data = points.map((p) => ({
|
||||
dist: applyDisplayTransform(p.distanceM, distance),
|
||||
alt: applyDisplayTransform(p.altitude, elevation),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="h-24 w-full" data-slot="workout-elevation-chart">
|
||||
@@ -57,7 +70,7 @@ export function WorkoutElevationChart({
|
||||
opacity={0.4}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="km"
|
||||
dataKey="dist"
|
||||
type="number"
|
||||
domain={[0, "dataMax"]}
|
||||
tick={{ fontSize: 10, fill: "var(--muted-foreground)" }}
|
||||
@@ -80,9 +93,11 @@ export function WorkoutElevationChart({
|
||||
borderRadius: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
labelFormatter={(km) => `${Number(km).toFixed(2)} km`}
|
||||
labelFormatter={(d) =>
|
||||
`${Number(d).toFixed(2)} ${distance.displayUnit}`
|
||||
}
|
||||
formatter={(value) => [
|
||||
`${Math.round(Number(value))} m`,
|
||||
`${Math.round(Number(value))} ${elevation.displayUnit}`,
|
||||
t("insights.workouts.detail.elevationTitle"),
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -225,6 +225,49 @@ describe("<ClinicianView>", () => {
|
||||
expect(html).not.toContain(">WEIGHT<");
|
||||
});
|
||||
|
||||
it("prints every measurement group with its unit", () => {
|
||||
const html = render(
|
||||
makeReport({
|
||||
stats: {
|
||||
WEIGHT: { avg: 80, min: 78, max: 82, count: 12, latest: 79 },
|
||||
// Per-night asleep minutes; the report reads them in hours.
|
||||
SLEEP_DURATION: {
|
||||
avg: 450,
|
||||
min: 420,
|
||||
max: 480,
|
||||
count: 7,
|
||||
latest: 450,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(html).toContain("latest 79 kg (avg 80 kg, range 78–82 kg)");
|
||||
expect(html).toContain("latest 7.5 h (avg 7.5 h, range 7–8 h)");
|
||||
});
|
||||
|
||||
it("shows glucose in the record owner's unit, never raw mg/dL", () => {
|
||||
const html = render(
|
||||
makeReport({
|
||||
glucoseUnit: "mmol/L",
|
||||
stats: {
|
||||
BLOOD_GLUCOSE: { avg: 108, min: 90, max: 126, count: 9, latest: 99 },
|
||||
},
|
||||
glucoseStats: {
|
||||
FASTING: { avg: 99, min: 90, max: 108, count: 5, latest: 95 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
// 99 mg/dL = 5.5 mmol/L, 108 = 6.0, 90 = 5.0, 126 = 7.0, 95 = 5.3.
|
||||
expect(html).toContain(
|
||||
"latest 5.5 mmol/L (avg 6 mmol/L, range 5–7 mmol/L)",
|
||||
);
|
||||
expect(html).toContain(
|
||||
"latest 5.3 mmol/L (avg 5.5 mmol/L, range 5–6 mmol/L)",
|
||||
);
|
||||
expect(html).not.toContain("mg/dL");
|
||||
expect(html).not.toContain("latest 99");
|
||||
});
|
||||
|
||||
it("omits the wellness card when there are no scores", () => {
|
||||
const html = render(makeReport({ wellnessScores: [] }));
|
||||
expect(html).not.toContain("Wellness scores");
|
||||
|
||||
@@ -15,6 +15,11 @@
|
||||
*/
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import type { DoctorReportData } from "@/lib/doctor-report-data";
|
||||
import {
|
||||
reportStatUnit,
|
||||
reportStatValue,
|
||||
} from "@/lib/doctor-report/stat-display";
|
||||
import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
|
||||
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/lib/measurements/type-label-keys";
|
||||
import {
|
||||
isReportLeafId,
|
||||
@@ -212,6 +217,13 @@ export function MeasurementGroups({
|
||||
|
||||
if (groups.length === 0) return null;
|
||||
|
||||
// The same unit and the same number the PDF prints for the row: SI for
|
||||
// mass, length and temperature, the owner's unit for glucose, hours for
|
||||
// sleep. A clinician must never read a figure without knowing its unit.
|
||||
const glucoseUnit = resolveGlucoseUnit(report.glucoseUnit ?? null);
|
||||
const show = (type: string, value: number) =>
|
||||
fmtNum(reportStatValue(type, value, glucoseUnit));
|
||||
|
||||
return (
|
||||
<>
|
||||
{groups.map((group) => (
|
||||
@@ -224,11 +236,11 @@ export function MeasurementGroups({
|
||||
<StatRow
|
||||
key={type}
|
||||
label={t(MEASUREMENT_TYPE_LABEL_KEYS[type])}
|
||||
value={t("clinicianView.statSummary", {
|
||||
latest: fmtNum(stat!.latest),
|
||||
avg: fmtNum(stat!.avg),
|
||||
min: fmtNum(stat!.min),
|
||||
max: fmtNum(stat!.max),
|
||||
value={statSummary(t, reportStatUnit(type, glucoseUnit, t), {
|
||||
latest: show(type, stat!.latest),
|
||||
avg: show(type, stat!.avg),
|
||||
min: show(type, stat!.min),
|
||||
max: show(type, stat!.max),
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
@@ -238,6 +250,20 @@ export function MeasurementGroups({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* "latest 79 kg (avg 80 kg, range 78–82 kg)". A type with no unit (a score, a
|
||||
* count) keeps the unit-less sentence rather than printing a dangling space.
|
||||
*/
|
||||
function statSummary(
|
||||
t: Translate,
|
||||
unit: string,
|
||||
values: { latest: number; avg: number; min: number; max: number },
|
||||
): string {
|
||||
return unit
|
||||
? t("clinicianView.statSummaryWithUnit", { ...values, unit })
|
||||
: t("clinicianView.statSummary", values);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-context glucose stats. The heading used to read "Lab values", which was
|
||||
* the wrong name for it even before the record's actual lab results reached
|
||||
@@ -258,6 +284,9 @@ export function GlucoseSection({
|
||||
const entries = Object.entries(report.glucoseStats).filter(
|
||||
([, s]) => s.count > 0,
|
||||
);
|
||||
// Stored in mg/dL; shown in the record owner's unit, as the PDF shows it.
|
||||
const glucoseUnit = resolveGlucoseUnit(report.glucoseUnit ?? null);
|
||||
const show = (value: number) => fmtNum(convertGlucose(value, glucoseUnit));
|
||||
return (
|
||||
<LeafSection
|
||||
t={t}
|
||||
@@ -270,11 +299,11 @@ export function GlucoseSection({
|
||||
<StatRow
|
||||
key={ctx}
|
||||
label={t(`clinicianView.glucose.${ctx}`)}
|
||||
value={t("clinicianView.statSummary", {
|
||||
latest: fmtNum(s.latest),
|
||||
avg: fmtNum(s.avg),
|
||||
min: fmtNum(s.min),
|
||||
max: fmtNum(s.max),
|
||||
value={statSummary(t, glucoseUnit, {
|
||||
latest: show(s.latest),
|
||||
avg: show(s.avg),
|
||||
min: show(s.min),
|
||||
max: show(s.max),
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -6,12 +6,17 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
// QueryClient, so mock the hook to a real metric-preference implementation —
|
||||
// metric is the identity on values and yields the canonical display symbols,
|
||||
// so the assertions below (kg, °C, unchanged numbers) hold.
|
||||
const reader = vi.hoisted(() => ({
|
||||
glucoseUnit: "mg/dL" as "mg/dL" | "mmol/L",
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-unit-display", async () => {
|
||||
const dt = await import("@/lib/measurements/display-transform");
|
||||
const forType = (t: string) => dt.getDisplayTransform(t, "metric");
|
||||
return {
|
||||
useUnitDisplay: () => ({
|
||||
preference: "metric" as const,
|
||||
glucoseUnit: reader.glucoseUnit,
|
||||
transformFor: forType,
|
||||
toDisplay: (t: string, v: number) =>
|
||||
dt.applyDisplayTransform(v, forType(t)),
|
||||
@@ -107,4 +112,26 @@ describe("<CyclePhaseCrosstab>", () => {
|
||||
const html = render(<CyclePhaseCrosstab rows={[unknown]} />);
|
||||
expect(html).not.toContain("undefined");
|
||||
});
|
||||
|
||||
it("states a glucose row in mmol/L for an mmol/L reader", () => {
|
||||
reader.glucoseUnit = "mmol/L";
|
||||
try {
|
||||
const glucose: CyclePhaseCrosstabRow = {
|
||||
...row("bloodGlucose", "glucose"),
|
||||
lutealAvg: 108,
|
||||
follicularAvg: 90,
|
||||
delta: 18,
|
||||
};
|
||||
const html = render(<CyclePhaseCrosstab rows={[glucose]} />);
|
||||
// 18 mg/dL is 1.0 mmol/L; 108 / 90 mg/dL are 6.0 / 5.0 mmol/L.
|
||||
expect(html).toContain("+1.0 mmol/L");
|
||||
expect(html).toContain("6.0");
|
||||
expect(html).not.toContain("mg/dL");
|
||||
expect(render(<CyclePhaseHeadline headline={glucose} />)).toContain(
|
||||
"mmol/L",
|
||||
);
|
||||
} finally {
|
||||
reader.glucoseUnit = "mg/dL";
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useUnitDisplay } from "@/hooks/use-unit-display";
|
||||
import { useUnitDisplay, type UnitDisplay } from "@/hooks/use-unit-display";
|
||||
import {
|
||||
applyDisplayTransform,
|
||||
applyDisplayTransformDelta,
|
||||
getReadingTransform,
|
||||
} from "@/lib/measurements/display-transform";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
@@ -88,8 +93,47 @@ function fmt(value: number, display: CyclePhaseCrosstabDisplay): string {
|
||||
const DISPLAY_TO_TYPE: Partial<Record<CyclePhaseCrosstabDisplay, string>> = {
|
||||
kg: "WEIGHT",
|
||||
celsius: "BODY_TEMPERATURE",
|
||||
glucose: "BLOOD_GLUCOSE",
|
||||
};
|
||||
|
||||
/**
|
||||
* Unit symbol and conversions for one row, from the reader's preferences.
|
||||
* Glucose follows the glucose unit (it used to print the mg/dL label from
|
||||
* the bundle beside raw mg/dL values for every reader); weight and
|
||||
* temperature follow metric/imperial; every other display keeps its bundle
|
||||
* label and its raw value.
|
||||
*/
|
||||
function rowUnits(
|
||||
display: CyclePhaseCrosstabDisplay,
|
||||
unitDisplay: UnitDisplay,
|
||||
t: (key: string) => string,
|
||||
): {
|
||||
unit: string;
|
||||
abs: (v: number) => number;
|
||||
delta: (v: number) => number;
|
||||
} {
|
||||
const type = DISPLAY_TO_TYPE[display];
|
||||
if (!type) {
|
||||
// Defensive: an unmapped display must never pass undefined to `t`
|
||||
// (whose resolver does `key.split(".")`). Empty unit on a miss.
|
||||
const unitKey = UNIT_KEY[display];
|
||||
return {
|
||||
unit: unitKey ? t(unitKey) : "",
|
||||
abs: (v) => v,
|
||||
delta: (v) => v,
|
||||
};
|
||||
}
|
||||
const transform = getReadingTransform(type, {
|
||||
system: unitDisplay.preference,
|
||||
glucoseUnit: unitDisplay.glucoseUnit,
|
||||
});
|
||||
return {
|
||||
unit: transform.displayUnit,
|
||||
abs: (v) => applyDisplayTransform(v, transform),
|
||||
delta: (v) => applyDisplayTransformDelta(v, transform),
|
||||
};
|
||||
}
|
||||
|
||||
const HEADLINE_KEY: Record<string, string> = {
|
||||
restingHeartRate: "cycle.insights.headline.restingHeartRate",
|
||||
heartRateVariability: "cycle.insights.headline.heartRateVariability",
|
||||
@@ -120,11 +164,7 @@ export function CyclePhaseHeadline({
|
||||
const metricLabel = t(
|
||||
METRIC_LABEL_KEY[headline.metricKey] ?? headline.metricKey,
|
||||
);
|
||||
// Defensive: an unmapped display must never pass undefined to `t` (whose
|
||||
// resolver does `key.split(".")`). An empty unit is the honest degrade.
|
||||
const mt = DISPLAY_TO_TYPE[headline.display];
|
||||
const unitKey = UNIT_KEY[headline.display];
|
||||
const unit = mt ? unitDisplay.unitFor(mt) : unitKey ? t(unitKey) : "";
|
||||
const { unit, delta: deltaConv } = rowUnits(headline.display, unitDisplay, t);
|
||||
const up = headline.delta >= 0;
|
||||
const dir = t(
|
||||
up
|
||||
@@ -134,12 +174,7 @@ export function CyclePhaseHeadline({
|
||||
const params = {
|
||||
metric: metricLabel,
|
||||
// A phase delta → factor-only conversion (no affine offset).
|
||||
delta: fmt(
|
||||
Math.abs(
|
||||
mt ? unitDisplay.toDisplayDelta(mt, headline.delta) : headline.delta,
|
||||
),
|
||||
headline.display,
|
||||
),
|
||||
delta: fmt(Math.abs(deltaConv(headline.delta)), headline.display),
|
||||
unit,
|
||||
dir,
|
||||
};
|
||||
@@ -175,15 +210,11 @@ export function CyclePhaseCrosstab({
|
||||
const metricLabel = t(
|
||||
METRIC_LABEL_KEY[row.metricKey] ?? row.metricKey,
|
||||
);
|
||||
// Defensive: an unmapped display must never pass undefined to `t`
|
||||
// (whose resolver does `key.split(".")`). Empty unit on a miss.
|
||||
const mt = DISPLAY_TO_TYPE[row.display];
|
||||
const unitKey = UNIT_KEY[row.display];
|
||||
const unit = mt ? unitDisplay.unitFor(mt) : unitKey ? t(unitKey) : "";
|
||||
const absConv = (v: number) =>
|
||||
mt ? unitDisplay.toDisplay(mt, v) : v;
|
||||
const deltaConv = (v: number) =>
|
||||
mt ? unitDisplay.toDisplayDelta(mt, v) : v;
|
||||
const {
|
||||
unit,
|
||||
abs: absConv,
|
||||
delta: deltaConv,
|
||||
} = rowUnits(row.display, unitDisplay, t);
|
||||
// `delta` = lutealAvg − follicularAvg: positive = the vital runs
|
||||
// higher in the luteal phase. The number stays NEUTRAL — higher is
|
||||
// good for steps but bad for resting HR, and this board is
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* A logged fever reads in the reader's temperature unit. The timeline and the
|
||||
* day sheet printed a hardcoded °C, so an imperial account read its own
|
||||
* fever in Celsius.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
const account = vi.hoisted(() => ({
|
||||
user: { unitPreference: "imperial", glucoseUnit: null } as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ user: account.user, isAuthenticated: true }),
|
||||
useAccountOnceMounted: () => account.user,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-record-capabilities", () => ({
|
||||
useRecordCapabilities: () => ({ canManageDomain: () => true }),
|
||||
}));
|
||||
|
||||
vi.mock("../use-illness", () => ({
|
||||
useIllnessDayLogList: () => ({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: {
|
||||
dayLogs: [
|
||||
{
|
||||
id: "d1",
|
||||
episodeId: "e1",
|
||||
date: "2026-09-30",
|
||||
functionalImpact: null,
|
||||
feverC: 38.5,
|
||||
symptoms: [],
|
||||
note: null,
|
||||
updatedAt: "2026-09-30T08:00:00.000Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { IllnessDayTimeline } from "../illness-day-timeline";
|
||||
|
||||
function render(): string {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<IllnessDayTimeline episodeId="e1" onLogDay={() => {}} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<IllnessDayTimeline> fever unit", () => {
|
||||
it("shows the fever in °F for an imperial reader", () => {
|
||||
account.user = { unitPreference: "imperial", glucoseUnit: null };
|
||||
const html = render();
|
||||
// 38.5 °C = 101.3 °F.
|
||||
expect(html).toContain("Temperature: 101.3 °F");
|
||||
expect(html).not.toContain("°C");
|
||||
});
|
||||
|
||||
it("keeps °C for a metric reader", () => {
|
||||
account.user = { unitPreference: "metric", glucoseUnit: null };
|
||||
expect(render()).toContain("Temperature: 38.5 °C");
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { useUnitDisplay } from "@/hooks/use-unit-display";
|
||||
|
||||
import { ILLNESS_SYMPTOM_CATALOG } from "./symptom-catalog";
|
||||
import { useIllnessDayLogList } from "./use-illness";
|
||||
@@ -32,6 +33,7 @@ function symptomLabelKey(key: string): string | null {
|
||||
function DayLogRow({ log }: { log: IllnessDayLogDTO }) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const { toDisplay, unitFor } = useUnitDisplay();
|
||||
|
||||
return (
|
||||
<div className="border-border/60 space-y-2 border-l-2 pl-3">
|
||||
@@ -72,7 +74,10 @@ function DayLogRow({ log }: { log: IllnessDayLogDTO }) {
|
||||
|
||||
{log.feverC !== null ? (
|
||||
<p className="text-foreground text-sm">
|
||||
{t("illness.timeline.fever", { value: fmt.number(log.feverC, 1) })}
|
||||
{t("illness.timeline.fever", {
|
||||
value: fmt.number(toDisplay("BODY_TEMPERATURE", log.feverC), 1),
|
||||
unit: unitFor("BODY_TEMPERATURE"),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useUnitDisplay } from "@/hooks/use-unit-display";
|
||||
|
||||
import { ILLNESS_SYMPTOM_CATALOG } from "./symptom-catalog";
|
||||
import { useIllnessDayLog, useUpsertDayLog } from "./use-illness";
|
||||
@@ -140,6 +141,10 @@ export function LogDaySheet({
|
||||
onsetDate,
|
||||
}: LogDaySheetProps) {
|
||||
const { t } = useTranslations();
|
||||
// The fever is stored in °C and typed in the reader's temperature unit:
|
||||
// seeded through the transform, inverted on save.
|
||||
const { toDisplay, fromDisplay, unitFor } = useUnitDisplay();
|
||||
const feverUnit = unitFor("BODY_TEMPERATURE");
|
||||
|
||||
// The day being logged — seeded from `date` (today), but the picker lets the
|
||||
// user backdate within [onset, today] for the retrospective journal.
|
||||
@@ -178,7 +183,11 @@ export function LogDaySheet({
|
||||
new Map((dto?.symptoms ?? []).map((s) => [s.key, s.severity ?? null])),
|
||||
);
|
||||
setFunctionalImpact(dto?.functionalImpact ?? null);
|
||||
setFever(dto?.feverC != null ? String(dto.feverC) : "");
|
||||
setFever(
|
||||
dto?.feverC != null
|
||||
? String(toDisplay("BODY_TEMPERATURE", dto.feverC))
|
||||
: "",
|
||||
);
|
||||
setNote(dto?.note ?? "");
|
||||
} else if (!open && lastFormKey !== null) {
|
||||
setLastFormKey(null);
|
||||
@@ -213,7 +222,10 @@ export function LogDaySheet({
|
||||
const input: IllnessDayLogInput = {
|
||||
date: selectedDate,
|
||||
functionalImpact,
|
||||
feverC: feverValue,
|
||||
feverC:
|
||||
feverValue == null
|
||||
? null
|
||||
: Math.round(fromDisplay("BODY_TEMPERATURE", feverValue) * 100) / 100,
|
||||
symptoms: Array.from(symptoms.entries()).map(([key, severity]) => ({
|
||||
key,
|
||||
severity: severity ?? undefined,
|
||||
@@ -333,7 +345,7 @@ export function LogDaySheet({
|
||||
summary={
|
||||
feverValue != null ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{feverValue.toFixed(1)} °C
|
||||
{feverValue.toFixed(1)} {feverUnit}
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
@@ -345,7 +357,7 @@ export function LogDaySheet({
|
||||
<Input
|
||||
id="illness-fever"
|
||||
inputMode="decimal"
|
||||
placeholder="37.0"
|
||||
placeholder={toDisplay("BODY_TEMPERATURE", 37).toFixed(1)}
|
||||
value={fever}
|
||||
onChange={(e) => setFever(e.target.value)}
|
||||
className="max-w-32"
|
||||
|
||||
@@ -21,14 +21,40 @@ import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
vi.mock("next/dynamic", () => ({
|
||||
default: () => {
|
||||
const Stub = ({ title }: { title?: string }) => (
|
||||
<div data-slot="trends-row-chart-stub">{title ?? "chart"}</div>
|
||||
const Stub = ({
|
||||
title,
|
||||
unit,
|
||||
valueScale,
|
||||
valueOffset,
|
||||
}: {
|
||||
title?: string;
|
||||
unit?: string;
|
||||
valueScale?: number;
|
||||
valueOffset?: number;
|
||||
}) => (
|
||||
<div
|
||||
data-slot="trends-row-chart-stub"
|
||||
data-unit={unit}
|
||||
data-scale={valueScale}
|
||||
data-offset={valueOffset}
|
||||
>
|
||||
{title ?? "chart"}
|
||||
</div>
|
||||
);
|
||||
Stub.displayName = "TrendsRowChartStub";
|
||||
return Stub;
|
||||
},
|
||||
}));
|
||||
|
||||
const account = vi.hoisted(() => ({
|
||||
user: null as Record<string, unknown> | null,
|
||||
}));
|
||||
vi.mock("@/hooks/use-auth", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/hooks/use-auth")>()),
|
||||
useAuth: () => ({ user: account.user, isAuthenticated: true }),
|
||||
useAccountOnceMounted: () => account.user,
|
||||
}));
|
||||
|
||||
import { TrendsRow } from "../trends-row";
|
||||
import type { DailyBriefing } from "@/lib/ai/schema";
|
||||
|
||||
@@ -306,4 +332,27 @@ describe("<TrendsRow>", () => {
|
||||
expect(html).toMatch(/data-metric="steps"/);
|
||||
expect(html).toContain('href="/insights/steps"');
|
||||
});
|
||||
|
||||
it("plots a transformed slot in the reader's unit, scale and offset included", () => {
|
||||
account.user = { unitPreference: "imperial", glucoseUnit: null };
|
||||
try {
|
||||
const html = render(
|
||||
<TrendsRow briefing={briefing(["weight", "distance", "body_temp"])} />,
|
||||
);
|
||||
expect(html).toContain('data-unit="lb"');
|
||||
expect(html).toContain('data-unit="mi"');
|
||||
expect(html).toContain('data-unit="°F"');
|
||||
expect(html).toContain('data-offset="32"');
|
||||
expect(html).not.toContain('data-unit="kg"');
|
||||
expect(html).not.toContain('data-unit="km"');
|
||||
} finally {
|
||||
account.user = null;
|
||||
}
|
||||
});
|
||||
|
||||
it("plots distance in kilometres, scaled from metres, for a metric reader", () => {
|
||||
const html = render(<TrendsRow briefing={briefing(["distance"])} />);
|
||||
expect(html).toContain('data-unit="km"');
|
||||
expect(html).toContain('data-scale="0.001"');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
@@ -16,6 +16,19 @@ import { WorkoutInsightCard } from "../workout-detail/insight-slot";
|
||||
import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
|
||||
import type { RouteCoordinate } from "@/lib/workouts/route-svg";
|
||||
|
||||
// The unit preference comes from the account payload; a mutable stub lets a
|
||||
// test switch the reader to imperial.
|
||||
const account = vi.hoisted(() => ({
|
||||
user: { unitPreference: "metric", glucoseUnit: null } as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
}));
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ user: account.user, isAuthenticated: true }),
|
||||
useAccountOnceMounted: () => account.user,
|
||||
}));
|
||||
|
||||
/**
|
||||
* #67 — `<WorkoutDetail*>` unit tests over the split `workout-detail/`
|
||||
* directory. Each primitive runs through SSR with a canonical fixture;
|
||||
@@ -149,6 +162,44 @@ describe("<WorkoutDetailStats>", () => {
|
||||
expect(html).toContain("148 bpm");
|
||||
});
|
||||
|
||||
it("states distance, pace, elevation and the average line in imperial units", () => {
|
||||
account.user = { unitPreference: "imperial", glucoseUnit: null };
|
||||
try {
|
||||
const html = render(
|
||||
<WorkoutDetailStats
|
||||
workout={{
|
||||
...FIXTURE,
|
||||
sportContext: {
|
||||
count: 8,
|
||||
avgDurationSec: 2040,
|
||||
avgDistanceM: 5800,
|
||||
avgAvgHr: 148,
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
// 5200 m = 3.23 mi; 1800 s over it = 9:17 per mile; 12.5 m = 41.0 ft.
|
||||
expect(html).toContain("3.23 mi");
|
||||
expect(html).toContain("9:17 /mi");
|
||||
expect(html).toContain("41.0 ft");
|
||||
// The sport-average line: 5800 m = 3.60 mi.
|
||||
expect(html).toContain("3.60 mi");
|
||||
expect(html).not.toContain(" km");
|
||||
expect(html).not.toContain("/km");
|
||||
const header = render(<WorkoutDetailHeader workout={FIXTURE} />);
|
||||
expect(header).toContain("3.23 mi");
|
||||
} finally {
|
||||
account.user = { unitPreference: "metric", glucoseUnit: null };
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps kilometres, minutes per km and metres for a metric reader", () => {
|
||||
const html = render(<WorkoutDetailStats workout={FIXTURE} />);
|
||||
expect(html).toContain("5.20 km");
|
||||
expect(html).toContain("5:46 /km");
|
||||
expect(html).toContain("12.5 m");
|
||||
});
|
||||
|
||||
it("hides the average line for a lone session", () => {
|
||||
const lone: WorkoutDetailPayload = {
|
||||
...FIXTURE,
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { WorkoutList } from "../workout-list";
|
||||
import type { WorkoutListEntry } from "@/hooks/use-workouts";
|
||||
|
||||
// The unit preference comes from the account payload; a mutable stub lets a
|
||||
// test switch the reader to imperial.
|
||||
const account = vi.hoisted(() => ({
|
||||
user: { unitPreference: "metric", glucoseUnit: null } as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
}));
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ user: account.user, isAuthenticated: true }),
|
||||
useAccountOnceMounted: () => account.user,
|
||||
}));
|
||||
|
||||
/**
|
||||
* v1.4.32 — `<WorkoutList>` unit tests.
|
||||
*
|
||||
@@ -47,6 +60,18 @@ describe("<WorkoutList>", () => {
|
||||
expect(html).toContain("320 kcal");
|
||||
});
|
||||
|
||||
it("states the distance in miles for an imperial reader", () => {
|
||||
account.user = { unitPreference: "imperial", glucoseUnit: null };
|
||||
try {
|
||||
const html = render(<WorkoutList workouts={[ROW]} />);
|
||||
// 5200 m = 3.23 mi.
|
||||
expect(html).toContain("3.23 mi");
|
||||
expect(html).not.toContain(" km");
|
||||
} finally {
|
||||
account.user = { unitPreference: "metric", glucoseUnit: null };
|
||||
}
|
||||
});
|
||||
|
||||
it("renders aggregate average HR without claiming an HR series", () => {
|
||||
const html = render(
|
||||
<WorkoutList workouts={[{ ...ROW, hasHrSeries: false }]} />,
|
||||
|
||||
@@ -8,7 +8,6 @@ import { useAuth } from "@/hooks/use-auth";
|
||||
import { useUnitDisplay } from "@/hooks/use-unit-display";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
|
||||
import { resolveTargetUnitAdapter } from "@/lib/targets/target-unit-display";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { RangeBar } from "@/components/targets/range-bar";
|
||||
@@ -18,6 +17,7 @@ import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { useTargetAdjust } from "@/lib/insights/target-adjust-context";
|
||||
import { getTargetSourceLink } from "@/lib/targets/source-link";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { resolveGlucoseUnit, type GlucoseUnit } from "@/lib/glucose";
|
||||
|
||||
/**
|
||||
* v1.8.0 → v1.8.5 — surface the per-metric target reference panel on each
|
||||
@@ -152,28 +152,17 @@ export function MetricTargetSummary({ slug }: MetricTargetSummaryProps) {
|
||||
// mg/dL values to the user's display unit. The route's per-context
|
||||
// labels are i18n keys, so resolve them.
|
||||
if (isGlucose) {
|
||||
const displayUnit = resolveGlucoseUnit(data.profile?.glucoseUnit ?? null);
|
||||
const convert = (v: number | null) =>
|
||||
v == null ? null : convertGlucose(v, displayUnit);
|
||||
// The payload stays canonical mg/dL here; each panel converts through
|
||||
// the shared target adapter, which owns the glucose unit as it owns
|
||||
// pounds, so the panel and its edit sheet cannot convert twice.
|
||||
const glucoseUnit = resolveGlucoseUnit(data.profile?.glucoseUnit ?? null);
|
||||
const panels = GLUCOSE_TARGET_TYPES.map((type) =>
|
||||
data.targets.find((entry) => entry.type === type),
|
||||
)
|
||||
.filter(
|
||||
(entry): entry is TargetItem => entry != null && entry.range != null,
|
||||
)
|
||||
.map((entry) => ({
|
||||
...entry,
|
||||
label: t(entry.label),
|
||||
unit: displayUnit,
|
||||
current: convert(entry.current),
|
||||
average30: convert(entry.average30),
|
||||
range: entry.range
|
||||
? {
|
||||
min: convertGlucose(entry.range.min, displayUnit),
|
||||
max: convertGlucose(entry.range.max, displayUnit),
|
||||
}
|
||||
: null,
|
||||
}));
|
||||
.map((entry) => ({ ...entry, label: t(entry.label) }));
|
||||
|
||||
if (panels.length === 0) return null;
|
||||
|
||||
@@ -188,6 +177,7 @@ export function MetricTargetSummary({ slug }: MetricTargetSummaryProps) {
|
||||
key={panel.type}
|
||||
target={panel}
|
||||
heading={panel.label}
|
||||
glucoseUnit={glucoseUnit}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -212,6 +202,12 @@ interface TargetReferencePanelProps {
|
||||
bpDiastolic?: TargetsResponse["bpDiastolic"];
|
||||
/** Optional sub-heading shown above the panel (used for glucose contexts). */
|
||||
heading?: string;
|
||||
/**
|
||||
* The record's glucose unit as the targets payload resolved it. The
|
||||
* glucose panels pass it so the band reads in the unit the server
|
||||
* resolved for this record; every other panel leaves it to the hook.
|
||||
*/
|
||||
glucoseUnit?: GlucoseUnit;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -232,21 +228,26 @@ function TargetReferencePanel({
|
||||
target,
|
||||
bpDiastolic,
|
||||
heading,
|
||||
glucoseUnit: payloadGlucoseUnit,
|
||||
}: TargetReferencePanelProps) {
|
||||
const { t } = useTranslations();
|
||||
const adjust = useTargetAdjust();
|
||||
const { preference } = useUnitDisplay();
|
||||
const { preference, glucoseUnit: accountGlucoseUnit } = useUnitDisplay();
|
||||
const glucoseUnit = payloadGlucoseUnit ?? accountGlucoseUnit;
|
||||
|
||||
// v1.32.27 — the panel renders in the user's preferred unit. The
|
||||
// `/api/insights/targets` payload is canonical (kg for weight); the
|
||||
// adapter converts it once, here, and everything downstream — the
|
||||
// range bar, the status pill, the 30-day average, and the edit
|
||||
// sheet's seed — consumes the converted pair. Glucose contexts are
|
||||
// pre-converted by the caller above and carry no display transform,
|
||||
// so they take the adapter's identity path and are never scaled
|
||||
// twice. For a metric account the adapter is the exact identity, so
|
||||
// this panel is byte-identical to the previous release.
|
||||
const units = resolveTargetUnitAdapter(target.type, target.unit, preference);
|
||||
// sheet's seed — consumes the converted pair. The glucose contexts
|
||||
// arrive canonical too and convert here to the reader's glucose unit.
|
||||
// On the default preferences the adapter is the exact identity.
|
||||
const units = resolveTargetUnitAdapter(
|
||||
target.type,
|
||||
target.unit,
|
||||
preference,
|
||||
glucoseUnit,
|
||||
);
|
||||
const unit = units.unit;
|
||||
const range = target.range
|
||||
? {
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CONFIDENCE_BADGE_CLASS } from "./confidence-badge";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useUnitDisplay } from "@/hooks/use-unit-display";
|
||||
|
||||
/**
|
||||
* v1.4.20 phase B3 — single-sentence AI annotation rendered directly
|
||||
@@ -264,6 +265,7 @@ export function TrendDescriptorCaption({
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { toDisplay, toDisplayDelta, unitFor } = useUnitDisplay();
|
||||
|
||||
const isMood = kind === "mood";
|
||||
const primaryType = types[0] ?? "";
|
||||
@@ -323,18 +325,30 @@ export function TrendDescriptorCaption({
|
||||
}
|
||||
// The numeric series is already server-bounded to the trailing
|
||||
// 30-day window by the fetch, so every returned row is in-window.
|
||||
// Described in the reader's unit, the same one the chart above plots.
|
||||
return (numericQuery.data ?? []).map((row) => ({
|
||||
timestamp: new Date(row.measuredAt).getTime(),
|
||||
value: row.value,
|
||||
value: toDisplay(primaryType, row.value),
|
||||
}));
|
||||
}, [isMood, moodQuery.data, numericQuery.data]);
|
||||
}, [isMood, moodQuery.data, numericQuery.data, toDisplay, primaryType]);
|
||||
|
||||
const descriptor = useMemo(() => {
|
||||
const meta = TREND_SLOT_DESCRIPTOR_META[metric];
|
||||
// A slot that converts its series converts its noise floor with it: the
|
||||
// floor is declared canonically and is a difference (factor only).
|
||||
const config = isMood
|
||||
? MOOD_DESCRIPTOR_CONFIG
|
||||
: TREND_SLOT_DESCRIPTOR_META[metric]?.config;
|
||||
: meta?.unitType
|
||||
? {
|
||||
...meta.config,
|
||||
absoluteFloor: toDisplayDelta(
|
||||
meta.unitType,
|
||||
meta.config.absoluteFloor,
|
||||
),
|
||||
}
|
||||
: meta?.config;
|
||||
return computeTrendDescriptor(points, config);
|
||||
}, [points, isMood, metric]);
|
||||
}, [points, isMood, metric, toDisplayDelta]);
|
||||
|
||||
// Tier 4 — genuinely too few points (also covers the in-flight window
|
||||
// before the small series lands). Surface the real empty hint so the
|
||||
@@ -361,7 +375,7 @@ export function TrendDescriptorCaption({
|
||||
// copy defensively (should not happen for the legacy triple).
|
||||
const copy = isMood
|
||||
? moodDescriptorCopy(descriptor)
|
||||
: (numericDescriptorCopy(metric, descriptor) ??
|
||||
: (numericDescriptorCopy(metric, descriptor, unitFor) ??
|
||||
moodDescriptorCopy(descriptor));
|
||||
|
||||
return (
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
type TrendAnnotationConfidenceBand,
|
||||
type TrendAnnotationStatus,
|
||||
} from "./trend-annotation";
|
||||
import { useUnitDisplay } from "@/hooks/use-unit-display";
|
||||
|
||||
/**
|
||||
* Trends row — a small chart per metric above a one-sentence
|
||||
@@ -116,17 +117,29 @@ function MetricChart({
|
||||
title: string;
|
||||
}) {
|
||||
const { user } = useAuth();
|
||||
const { transformFor, isTransformed } = useUnitDisplay();
|
||||
const userTimezone = user?.timezone;
|
||||
if (config.kind === "mood") {
|
||||
return <MoodChart title={title} mini />;
|
||||
}
|
||||
// A single transformed series (weight, distance, temperature) plots in
|
||||
// the reader's unit: the chart folds scale and offset in at its read
|
||||
// boundary, exactly as the metric's own insights page does. Distance
|
||||
// used to plot raw metres under a "km" label for every reader.
|
||||
const primary = config.types[0] ?? "";
|
||||
const transform =
|
||||
config.types.length === 1 && isTransformed(primary)
|
||||
? transformFor(primary)
|
||||
: null;
|
||||
return (
|
||||
<HealthChartDynamicMini
|
||||
types={config.types}
|
||||
title={title}
|
||||
colors={config.colors}
|
||||
unit={config.unit}
|
||||
yAxisUnit={config.yAxisUnit}
|
||||
unit={transform ? transform.displayUnit : config.unit}
|
||||
yAxisUnit={transform ? undefined : config.yAxisUnit}
|
||||
valueScale={transform?.factor}
|
||||
valueOffset={transform?.offset}
|
||||
mini
|
||||
userTimezone={userTimezone}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { getNumberFormat, getDateTimeFormat } from "@/lib/intl/formatter-cache";
|
||||
import {
|
||||
applyDisplayTransform,
|
||||
getQuantityTransform,
|
||||
paceSecondsPerDistanceUnit,
|
||||
type UnitPreference,
|
||||
} from "@/lib/measurements/display-transform";
|
||||
import {
|
||||
hourCycleOptions,
|
||||
type TimeFormatPreference,
|
||||
@@ -27,12 +33,38 @@ export function formatDurationMinutes(seconds: number, locale: string): string {
|
||||
return formatNumber(minutes, locale);
|
||||
}
|
||||
|
||||
export function formatDistanceKm(meters: number, locale: string): string {
|
||||
const km = meters / 1000;
|
||||
return getNumberFormat(locale, {
|
||||
/**
|
||||
* A workout distance in the reader's unit, with its symbol: "5.80 km" or
|
||||
* "3.60 mi". The workout list, the detail header, the stat tile and the
|
||||
* sport-average line all print through this, so none of them can keep
|
||||
* kilometres for an imperial reader.
|
||||
*/
|
||||
export function formatDistance(
|
||||
meters: number,
|
||||
locale: string,
|
||||
preference: UnitPreference,
|
||||
): string {
|
||||
const transform = getQuantityTransform("distance", preference);
|
||||
const value = applyDisplayTransform(meters, transform);
|
||||
const formatted = getNumberFormat(locale, {
|
||||
maximumFractionDigits: 2,
|
||||
minimumFractionDigits: km < 10 ? 2 : 1,
|
||||
}).format(km);
|
||||
minimumFractionDigits: value < 10 ? 2 : 1,
|
||||
}).format(value);
|
||||
return `${formatted} ${transform.displayUnit}`;
|
||||
}
|
||||
|
||||
/** Elevation gain in the reader's unit with its symbol: "123.4 m" / "404.9 ft". */
|
||||
export function formatElevation(
|
||||
meters: number,
|
||||
locale: string,
|
||||
preference: UnitPreference,
|
||||
): string {
|
||||
const transform = getQuantityTransform("elevation", preference);
|
||||
return `${formatNumber(
|
||||
applyDisplayTransform(meters, transform),
|
||||
locale,
|
||||
transform.decimals,
|
||||
)} ${transform.displayUnit}`;
|
||||
}
|
||||
|
||||
export function formatNumber(
|
||||
@@ -46,21 +78,36 @@ export function formatNumber(
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
export function formatPaceMinPerKm(
|
||||
/**
|
||||
* Average pace per kilometre or per mile, by the reader's preference. Only
|
||||
* meaningful for run / walk / hike / ride; the caller gates on distance +
|
||||
* sport.
|
||||
*/
|
||||
export function formatPace(
|
||||
durationSec: number,
|
||||
meters: number,
|
||||
preference: UnitPreference,
|
||||
): string {
|
||||
// Seconds-per-kilometre. Only meaningful for run / walk / hike / ride;
|
||||
// the caller gates on distance + sport.
|
||||
const secPerKm = (durationSec / meters) * 1000;
|
||||
return formatPaceSeconds(secPerKm);
|
||||
return formatPaceSeconds(
|
||||
paceSecondsPerDistanceUnit(durationSec, meters, preference),
|
||||
getQuantityTransform("distance", preference).displayUnit,
|
||||
);
|
||||
}
|
||||
|
||||
/** "m:ss /km" from a seconds-per-km value (used by pace + splits). */
|
||||
export function formatPaceSeconds(secPerKm: number): string {
|
||||
const m = Math.floor(secPerKm / 60);
|
||||
const s = Math.round(secPerKm % 60);
|
||||
return `${m}:${s.toString().padStart(2, "0")} /km`;
|
||||
/**
|
||||
* "m:ss /km" from a seconds-per-unit value. The splits table passes "km"
|
||||
* explicitly: its rows are kilometre segments cut server-side, so their
|
||||
* pace is per kilometre whatever the preference.
|
||||
*/
|
||||
export function formatPaceSeconds(secPerUnit: number, unit: string): string {
|
||||
let m = Math.floor(secPerUnit / 60);
|
||||
let s = Math.round(secPerUnit % 60);
|
||||
// 359.6 s is 6:00, not 5:60.
|
||||
if (s === 60) {
|
||||
m += 1;
|
||||
s = 0;
|
||||
}
|
||||
return `${m}:${s.toString().padStart(2, "0")} /${unit}`;
|
||||
}
|
||||
|
||||
export function formatDateRange(
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context";
|
||||
import { iconForSport } from "@/lib/workouts/sport-icons";
|
||||
import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
|
||||
import { useUnitDisplay } from "@/hooks/use-unit-display";
|
||||
|
||||
import { formatDuration, formatDistanceKm, formatDateRange } from "./format";
|
||||
import { formatDuration, formatDistance, formatDateRange } from "./format";
|
||||
|
||||
/**
|
||||
* Hero header — the at-a-glance verdict row. Sport icon badge, source
|
||||
@@ -30,6 +31,7 @@ function renderSportIconBadge(sportType: string) {
|
||||
export function WorkoutDetailHeader({ workout }: WorkoutDetailHeaderProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
const timeFormat = useTimeFormatPreference();
|
||||
const { preference } = useUnitDisplay();
|
||||
const sportLabelKey = `insights.workouts.sport.${workout.sportType}`;
|
||||
const sportLabel = t(sportLabelKey);
|
||||
const sportName =
|
||||
@@ -67,7 +69,7 @@ export function WorkoutDetailHeader({ workout }: WorkoutDetailHeaderProps) {
|
||||
</span>
|
||||
{workout.distanceM != null ? (
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{formatDistanceKm(workout.distanceM, locale)} km
|
||||
{formatDistance(workout.distanceM, locale, preference)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -70,7 +70,7 @@ export function WorkoutDetailSplits({ workout }: WorkoutDetailSplitsProps) {
|
||||
: "py-1.5 text-right tabular-nums"
|
||||
}
|
||||
>
|
||||
{formatPaceSeconds(s.paceSecPerKm)}
|
||||
{formatPaceSeconds(s.paceSecPerKm, "km")}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -17,12 +17,15 @@ import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
|
||||
import { useUnitDisplay } from "@/hooks/use-unit-display";
|
||||
import type { UnitPreference } from "@/lib/measurements/display-transform";
|
||||
|
||||
import {
|
||||
formatDuration,
|
||||
formatDistanceKm,
|
||||
formatDistance,
|
||||
formatElevation,
|
||||
formatNumber,
|
||||
formatPaceMinPerKm,
|
||||
formatPace,
|
||||
formatDurationMinutes,
|
||||
} from "./format";
|
||||
|
||||
@@ -69,12 +72,13 @@ const PACE_SPORTS = new Set(["walking", "running", "hiking", "cycling"]);
|
||||
function sportAverageLine(
|
||||
workout: WorkoutDetailPayload,
|
||||
locale: string,
|
||||
preference: UnitPreference,
|
||||
): string | null {
|
||||
const ctx = workout.sportContext;
|
||||
if (!ctx || ctx.count < 2) return null;
|
||||
const parts: string[] = [];
|
||||
if (ctx.avgDistanceM != null && ctx.avgDistanceM > 0) {
|
||||
parts.push(`${formatDistanceKm(ctx.avgDistanceM, locale)} km`);
|
||||
parts.push(formatDistance(ctx.avgDistanceM, locale, preference));
|
||||
}
|
||||
parts.push(`${formatDurationMinutes(ctx.avgDurationSec, locale)} min`);
|
||||
if (ctx.avgAvgHr != null) parts.push(`${ctx.avgAvgHr} bpm`);
|
||||
@@ -83,6 +87,7 @@ function sportAverageLine(
|
||||
|
||||
export function WorkoutDetailStats({ workout }: WorkoutDetailStatsProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
const { preference } = useUnitDisplay();
|
||||
const tiles: StatTileProps[] = [];
|
||||
|
||||
tiles.push({
|
||||
@@ -95,7 +100,7 @@ export function WorkoutDetailStats({ workout }: WorkoutDetailStatsProps) {
|
||||
tiles.push({
|
||||
icon: <Map className="size-4" />,
|
||||
label: t("insights.workouts.detail.statsDistance"),
|
||||
value: `${formatDistanceKm(workout.distanceM, locale)} km`,
|
||||
value: formatDistance(workout.distanceM, locale, preference),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -139,7 +144,7 @@ export function WorkoutDetailStats({ workout }: WorkoutDetailStatsProps) {
|
||||
tiles.push({
|
||||
icon: <Mountain className="size-4" />,
|
||||
label: t("insights.workouts.detail.statsElevation"),
|
||||
value: `${formatNumber(workout.elevationM, locale, 1)} m`,
|
||||
value: formatElevation(workout.elevationM, locale, preference),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -151,11 +156,11 @@ export function WorkoutDetailStats({ workout }: WorkoutDetailStatsProps) {
|
||||
tiles.push({
|
||||
icon: <Timer className="size-4" />,
|
||||
label: t("insights.workouts.detail.statsPace"),
|
||||
value: formatPaceMinPerKm(workout.durationSec, workout.distanceM),
|
||||
value: formatPace(workout.durationSec, workout.distanceM, preference),
|
||||
});
|
||||
}
|
||||
|
||||
const averageLine = sportAverageLine(workout, locale);
|
||||
const averageLine = sportAverageLine(workout, locale, preference);
|
||||
|
||||
return (
|
||||
<Card data-slot="workout-detail-stats">
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
import { iconForSport } from "@/lib/workouts/sport-icons";
|
||||
import type { WorkoutListEntry } from "@/hooks/use-workouts";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useUnitDisplay } from "@/hooks/use-unit-display";
|
||||
import { formatDistance } from "@/components/insights/workout-detail/format";
|
||||
|
||||
/**
|
||||
* v1.4.32 — workout list row primitive.
|
||||
@@ -33,14 +35,6 @@ function formatDuration(seconds: number): string {
|
||||
return `${m}m`;
|
||||
}
|
||||
|
||||
function formatDistanceKm(meters: number, locale: string): string {
|
||||
const km = meters / 1000;
|
||||
return getNumberFormat(locale, {
|
||||
maximumFractionDigits: 2,
|
||||
minimumFractionDigits: km < 10 ? 2 : 1,
|
||||
}).format(km);
|
||||
}
|
||||
|
||||
function formatEnergy(kcal: number, locale: string): string {
|
||||
return getNumberFormat(locale, {
|
||||
maximumFractionDigits: 0,
|
||||
@@ -86,6 +80,7 @@ export function WorkoutList({
|
||||
}: WorkoutListProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
const timeFormat = useTimeFormatPreference();
|
||||
const { preference } = useUnitDisplay();
|
||||
|
||||
return (
|
||||
<ul
|
||||
@@ -150,7 +145,7 @@ export function WorkoutList({
|
||||
<span className="text-muted-foreground flex flex-wrap justify-end gap-x-2 tabular-nums">
|
||||
{workout.distanceM != null ? (
|
||||
<span data-slot="workout-list-distance">
|
||||
{formatDistanceKm(workout.distanceM, locale)} km
|
||||
{formatDistance(workout.distanceM, locale, preference)}
|
||||
</span>
|
||||
) : null}
|
||||
{workout.activeEnergyKcal != null ? (
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* The settings threshold editor reads glucose in the reader's unit.
|
||||
*
|
||||
* It used to pass every glucose threshold through the identity path, so an
|
||||
* mmol/L account was shown "Default: 70–99 mg/dL" and mg/dL input fields
|
||||
* bounded at 40–400, while the insights sheet for the same threshold already
|
||||
* spoke mmol/L. The stored value stays canonical mg/dL either way.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
const authUser = vi.hoisted(() => ({
|
||||
current: { glucoseUnit: "mmol/L", unitPreference: "metric" } as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ user: authUser.current, isAuthenticated: true }),
|
||||
useAccountOnceMounted: () => authUser.current,
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({
|
||||
isLoading: false,
|
||||
data: {
|
||||
effective: {
|
||||
BLOOD_GLUCOSE_FASTING: {
|
||||
range: { greenMin: 72, greenMax: 108 },
|
||||
isOverride: true,
|
||||
default: { greenMin: 70, greenMax: 99 },
|
||||
bounds: { min: 40, max: 400, unit: "mg/dL" },
|
||||
},
|
||||
},
|
||||
overrides: { BLOOD_GLUCOSE_FASTING: { min: 72, max: 108 } },
|
||||
},
|
||||
}),
|
||||
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { ThresholdsEditorSection } from "../thresholds-editor-section";
|
||||
|
||||
function render(): string {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<ThresholdsEditorSection id="thresholds" />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<ThresholdsEditorSection> glucose unit", () => {
|
||||
it("states the default, the fields and the bounds in mmol/L", () => {
|
||||
authUser.current = { glucoseUnit: "mmol/L", unitPreference: "metric" };
|
||||
const html = render();
|
||||
// 70–99 mg/dL is 3.9–5.5 mmol/L.
|
||||
expect(html).toMatch(/3\.9–5\.5 mmol\/L/);
|
||||
expect(html).not.toMatch(/70(\.0)?–99(\.0)? mg\/dL/);
|
||||
// The stored 72–108 mg/dL override seeds 4–6 mmol/L.
|
||||
expect(html).toContain('value="4"');
|
||||
expect(html).toContain('value="6"');
|
||||
// Inward-rounded guardrail of 40–400 mg/dL.
|
||||
expect(html).toContain('min="2.3"');
|
||||
expect(html).toContain('max="22.1"');
|
||||
});
|
||||
|
||||
it("keeps mg/dL for an mg/dL reader", () => {
|
||||
authUser.current = { glucoseUnit: "mg/dL", unitPreference: "metric" };
|
||||
const html = render();
|
||||
expect(html).toMatch(/70(\.0)?–99(\.0)? mg\/dL/);
|
||||
expect(html).toContain('value="72"');
|
||||
expect(html).toContain('min="40"');
|
||||
});
|
||||
});
|
||||
@@ -237,7 +237,7 @@ function MetricRow({
|
||||
}: MetricRowProps) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const { preference } = useUnitDisplay();
|
||||
const { preference, glucoseUnit } = useUnitDisplay();
|
||||
const canonicalBounds = METRIC_BOUNDS[metric];
|
||||
|
||||
// v1.32.27 — the row reads and writes in the user's preferred unit
|
||||
@@ -246,12 +246,14 @@ function MetricRow({
|
||||
// canonical, so it converts on the way into the fields and inverts on
|
||||
// the way back out in `onSave`. Guardrails round inward so a value
|
||||
// typed at the displayed limit still passes the server's canonical
|
||||
// check. Metrics without a transform — and every metric for a metric
|
||||
// user — take the adapter's identity path untouched.
|
||||
// check. The glucose thresholds read in the reader's glucose unit the
|
||||
// same way. Metrics without a transform — and every metric on the default
|
||||
// preferences — take the adapter's identity path untouched.
|
||||
const units = resolveTargetUnitAdapter(
|
||||
metric,
|
||||
canonicalBounds.unit,
|
||||
preference,
|
||||
glucoseUnit,
|
||||
);
|
||||
const bounds = { ...units.bounds(canonicalBounds), unit: units.unit };
|
||||
|
||||
|
||||
@@ -17,8 +17,11 @@ import {
|
||||
type ThresholdMetric,
|
||||
type EffectiveRange,
|
||||
} from "@/lib/analytics/effective-range";
|
||||
import { convertGlucose, toCanonicalMgdl } from "@/lib/glucose";
|
||||
import { resolveTargetUnitAdapter } from "@/lib/targets/target-unit-display";
|
||||
import {
|
||||
isGlucoseTargetMetric,
|
||||
resolveTargetUnitAdapter,
|
||||
} from "@/lib/targets/target-unit-display";
|
||||
import { resolveGlucoseUnit } from "@/lib/glucose";
|
||||
import { useUnitDisplay } from "@/hooks/use-unit-display";
|
||||
import { apiDelete, apiGet, apiPut } from "@/lib/api/api-fetch";
|
||||
|
||||
@@ -129,49 +132,33 @@ function TargetEditSheetBody({
|
||||
const metric = TARGET_TYPE_TO_METRIC[targetType] ?? null;
|
||||
const isDerivedMetric = !isBp && metric == null;
|
||||
|
||||
// Glucose is the one metric whose display unit can differ from the
|
||||
// canonical storage unit: HealthLog stores mg/dL, but a user on the
|
||||
// mmol/L preference sees — and types — mmol/L. The parent already
|
||||
// hands us `unit="mmol/L"` and a `initialRange` pre-converted to that
|
||||
// unit. We must therefore (a) seed the mg/dL persisted override into
|
||||
// the display unit, (b) validate the typed value against bounds
|
||||
// expressed in the display unit, and (c) convert the typed value back
|
||||
// to canonical mg/dL before the PUT — otherwise a `5.5 mmol/L` target
|
||||
// is rejected by the 40–400 mg/dL bounds or stored verbatim as 5.5.
|
||||
const isGlucoseMmol =
|
||||
metric != null && metric.startsWith("BLOOD_GLUCOSE") && unit === "mmol/L";
|
||||
|
||||
// v1.32.27 — the same coupled treatment now covers the metric/imperial
|
||||
// preference. Weight-class thresholds are stored in kg; an imperial
|
||||
// user seeds, validates, and types in lb, and the adapter inverts the
|
||||
// typed number back to kg before the PUT. Glucose keeps its own
|
||||
// mg/dL ↔ mmol/L dialect (it carries no display transform, so the two
|
||||
// never compose); every other metric takes the adapter's identity
|
||||
// path and is numerically untouched.
|
||||
const { preference } = useUnitDisplay();
|
||||
// One adapter owns every direction of the unit conversion: an imperial
|
||||
// user seeds, validates and types weight-class thresholds in pounds, an
|
||||
// mmol/L user types glucose thresholds in mmol/L, and the typed number is
|
||||
// inverted back to the canonical kg / mg/dL before the PUT. The parent
|
||||
// hands `unit` and `initialRange` already in the display unit; the
|
||||
// persisted override and `METRIC_BOUNDS` are canonical and go through the
|
||||
// adapter. Every other metric takes the identity path.
|
||||
// A glucose sheet speaks the unit its panel registered, so the sheet and
|
||||
// the band it edits can never disagree.
|
||||
const { preference, glucoseUnit: accountGlucoseUnit } = useUnitDisplay();
|
||||
const adapterKey = isBp ? "BLOOD_PRESSURE_SYS" : (metric ?? "");
|
||||
const units = resolveTargetUnitAdapter(
|
||||
isBp ? "BLOOD_PRESSURE_SYS" : (metric ?? ""),
|
||||
adapterKey,
|
||||
unit,
|
||||
preference,
|
||||
isGlucoseTargetMetric(adapterKey)
|
||||
? resolveGlucoseUnit(unit)
|
||||
: accountGlucoseUnit,
|
||||
);
|
||||
|
||||
const toDisplay = (canonical: number) =>
|
||||
isGlucoseMmol
|
||||
? convertGlucose(canonical, "mmol/L")
|
||||
: units.toDisplay(canonical);
|
||||
const toCanonical = (displayValue: number) =>
|
||||
isGlucoseMmol
|
||||
? toCanonicalMgdl(displayValue, "mmol/L")
|
||||
: units.toCanonical(displayValue);
|
||||
// Guardrails the typed value is checked against, expressed in the
|
||||
// display unit. Glucose projects its mg/dL window through the same
|
||||
// conversion the fields use; every other metric rounds the window
|
||||
// INWARD so a value typed at the display bound never inverts to a
|
||||
// canonical value the server rejects.
|
||||
const toDisplay = (canonical: number) => units.toDisplay(canonical);
|
||||
const toCanonical = (displayValue: number) => units.toCanonical(displayValue);
|
||||
// Guardrails the typed value is checked against, expressed in the display
|
||||
// unit and rounded INWARD so a value typed at the display bound never
|
||||
// inverts to a canonical value the server rejects.
|
||||
const toDisplayBounds = (canonical: { min: number; max: number }) =>
|
||||
isGlucoseMmol
|
||||
? { min: toDisplay(canonical.min), max: toDisplay(canonical.max) }
|
||||
: units.bounds(canonical);
|
||||
units.bounds(canonical);
|
||||
|
||||
// Lazy-load the thresholds payload so the dialog also catches any
|
||||
// already-persisted override even when the seeded `initialRange`
|
||||
|
||||
Reference in new issue
Block a user