@@ -72,7 +74,10 @@ function DayLogRow({ log }: { log: IllnessDayLogDTO }) {
{log.feverC !== null ? (
- {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"),
+ })}
) : null}
diff --git a/src/components/illness/log-day-sheet.tsx b/src/components/illness/log-day-sheet.tsx
index 0afacddc7..b5d7e69da 100644
--- a/src/components/illness/log-day-sheet.tsx
+++ b/src/components/illness/log-day-sheet.tsx
@@ -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 ? (
- {feverValue.toFixed(1)} °C
+ {feverValue.toFixed(1)} {feverUnit}
) : null
}
@@ -345,7 +357,7 @@ export function LogDaySheet({
setFever(e.target.value)}
className="max-w-32"
diff --git a/src/components/insights/__tests__/trends-row.test.tsx b/src/components/insights/__tests__/trends-row.test.tsx
index c8b18b288..8b85cfd45 100644
--- a/src/components/insights/__tests__/trends-row.test.tsx
+++ b/src/components/insights/__tests__/trends-row.test.tsx
@@ -21,14 +21,40 @@ import { I18nProvider } from "@/lib/i18n/context";
vi.mock("next/dynamic", () => ({
default: () => {
- const Stub = ({ title }: { title?: string }) => (
-
{title ?? "chart"}
+ const Stub = ({
+ title,
+ unit,
+ valueScale,
+ valueOffset,
+ }: {
+ title?: string;
+ unit?: string;
+ valueScale?: number;
+ valueOffset?: number;
+ }) => (
+
+ {title ?? "chart"}
+
);
Stub.displayName = "TrendsRowChartStub";
return Stub;
},
}));
+const account = vi.hoisted(() => ({
+ user: null as Record
| null,
+}));
+vi.mock("@/hooks/use-auth", async (importOriginal) => ({
+ ...(await importOriginal()),
+ 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("", () => {
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(
+ ,
+ );
+ 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();
+ expect(html).toContain('data-unit="km"');
+ expect(html).toContain('data-scale="0.001"');
+ });
});
diff --git a/src/components/insights/trend-annotation.tsx b/src/components/insights/trend-annotation.tsx
index 5ce48d013..5b72e9ba9 100644
--- a/src/components/insights/trend-annotation.tsx
+++ b/src/components/insights/trend-annotation.tsx
@@ -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, transformFor, unitFor } = useUnitDisplay();
const isMood = kind === "mood";
const primaryType = types[0] ?? "";
@@ -323,18 +325,28 @@ 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 (a
+ // floor is a difference: factor only).
const config = isMood
? MOOD_DESCRIPTOR_CONFIG
- : TREND_SLOT_DESCRIPTOR_META[metric]?.config;
+ : meta?.unitType
+ ? {
+ ...meta.config,
+ absoluteFloor:
+ meta.config.absoluteFloor * transformFor(meta.unitType).factor,
+ }
+ : meta?.config;
return computeTrendDescriptor(points, config);
- }, [points, isMood, metric]);
+ }, [points, isMood, metric, transformFor]);
// 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 +373,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 (
diff --git a/src/components/insights/trends-row.tsx b/src/components/insights/trends-row.tsx
index a7056fd80..9e7b730fa 100644
--- a/src/components/insights/trends-row.tsx
+++ b/src/components/insights/trends-row.tsx
@@ -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 ;
}
+ // 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 (
diff --git a/src/lib/insights/__tests__/trend-descriptor.test.ts b/src/lib/insights/__tests__/trend-descriptor.test.ts
index 9b46547e2..cf7258f7b 100644
--- a/src/lib/insights/__tests__/trend-descriptor.test.ts
+++ b/src/lib/insights/__tests__/trend-descriptor.test.ts
@@ -106,13 +106,31 @@ describe("computeTrendDescriptor", () => {
});
describe("numericDescriptorCopy", () => {
+ it("states a preference-bound slot in the unit the caller resolves", () => {
+ const d = computeTrendDescriptor(series([181.6, 178.5]), {
+ absoluteFloor: 0.66,
+ relativeFloor: 0.01,
+ decimals: 1,
+ })!;
+ const copy = numericDescriptorCopy("weight", d, (type) =>
+ type === "WEIGHT" ? "lb" : "?",
+ );
+ expect(copy?.params.unit).toBe(" lb");
+ expect(TREND_SLOT_DESCRIPTOR_META.distance.unitType).toBe(
+ "WALKING_RUNNING_DISTANCE",
+ );
+ expect(TREND_SLOT_DESCRIPTOR_META.body_temp.unitType).toBe(
+ "BODY_TEMPERATURE",
+ );
+ });
+
it("resolves the rising template with a signed delta and spaced unit", () => {
const d = computeTrendDescriptor(series([120, 128]), {
absoluteFloor: 2,
relativeFloor: 0.02,
decimals: 0,
})!;
- const copy = numericDescriptorCopy("bp", d);
+ const copy = numericDescriptorCopy("bp", d, () => "unused");
expect(copy?.key).toBe("insights.trendDescriptor.rising");
expect(copy?.params.delta).toBe("+8");
expect(copy?.params.unit).toBe(" mmHg");
@@ -124,7 +142,7 @@ describe("numericDescriptorCopy", () => {
relativeFloor: 0.05,
decimals: 0,
})!;
- const copy = numericDescriptorCopy("steps", d);
+ const copy = numericDescriptorCopy("steps", d, () => "unused");
expect(copy?.key).toBe("insights.trendDescriptor.rising");
expect(copy?.params.unit).toBe("");
});
@@ -135,14 +153,14 @@ describe("numericDescriptorCopy", () => {
relativeFloor: 0.01,
decimals: 1,
})!;
- const copy = numericDescriptorCopy("weight", d);
+ const copy = numericDescriptorCopy("weight", d, () => "kg");
expect(copy?.key).toBe("insights.trendDescriptor.falling");
expect(copy?.params.delta).toBe("−1.4");
});
it("returns null for a slot with no numeric meta (mood)", () => {
const d = computeTrendDescriptor(series([3, 4]), MOOD_DESCRIPTOR_CONFIG)!;
- expect(numericDescriptorCopy("mood", d)).toBeNull();
+ expect(numericDescriptorCopy("mood", d, () => "")).toBeNull();
});
it("covers every numeric slot in the descriptor meta", () => {
@@ -154,7 +172,7 @@ describe("numericDescriptorCopy", () => {
relativeFloor: 0,
decimals: 1,
})!;
- expect(numericDescriptorCopy(metric, d)).not.toBeNull();
+ expect(numericDescriptorCopy(metric, d, () => "u")).not.toBeNull();
}
});
});
diff --git a/src/lib/insights/trend-chart-select.ts b/src/lib/insights/trend-chart-select.ts
index fc87a27c2..1ed5b8bc9 100644
--- a/src/lib/insights/trend-chart-select.ts
+++ b/src/lib/insights/trend-chart-select.ts
@@ -51,7 +51,12 @@ export interface TrendChartConfig {
types: string[];
/** Recharts stroke colours, positionally matched to `types`. */
colors: string[];
- /** Display unit (chart legend / tooltip). */
+ /**
+ * Display unit (chart legend / tooltip) for a metric no preference
+ * changes. A single-series slot whose type has a display transform
+ * (weight, distance, body temperature) carries none: the renderer takes
+ * the unit, scale and offset from the reader's preference instead.
+ */
unit?: string;
/** Y-axis unit label when it differs from the value unit. */
yAxisUnit?: string;
@@ -114,7 +119,6 @@ export const TREND_CHART_CONFIG: Record<
kind: "health-chart",
types: ["WEIGHT"],
colors: ["var(--chart-1)"],
- unit: "kg",
titleKey: "charts.weight",
captionKey: "insights.trendsRow.caption.weight",
annotationKey: "weight",
@@ -212,8 +216,6 @@ export const TREND_CHART_CONFIG: Record<
kind: "health-chart",
types: ["WALKING_RUNNING_DISTANCE"],
colors: ["var(--chart-4)"],
- unit: "km",
- yAxisUnit: "km",
titleKey: "charts.distance",
captionKey: "insights.trendsRow.caption.distance",
detailHref: "/insights/walking-distance",
@@ -234,8 +236,6 @@ export const TREND_CHART_CONFIG: Record<
kind: "health-chart",
types: ["BODY_TEMPERATURE"],
colors: ["var(--destructive)"],
- unit: "°C",
- yAxisUnit: "°C",
titleKey: "charts.bodyTemperature",
captionKey: "insights.trendsRow.caption.body_temp",
detailHref: "/insights/body-temperature",
diff --git a/src/lib/insights/trend-descriptor.ts b/src/lib/insights/trend-descriptor.ts
index b01ee8ffb..f42dd31aa 100644
--- a/src/lib/insights/trend-descriptor.ts
+++ b/src/lib/insights/trend-descriptor.ts
@@ -140,8 +140,17 @@ export function computeTrendDescriptor(
* dedicated copy rather than the numeric `{delta} {unit}` template.
*/
export interface TrendSlotDescriptorMeta {
- /** Display unit interpolated into the numeric descriptor template. */
- unit: string;
+ /**
+ * Display unit interpolated into the numeric descriptor template, for a
+ * metric no preference changes.
+ */
+ unit?: string;
+ /**
+ * For a metric whose unit follows the reader's preference: the
+ * measurement type the caller resolves the unit of (and converts the
+ * series and the noise floor through) before describing it.
+ */
+ unitType?: string;
config: TrendDescriptorConfig;
}
@@ -154,7 +163,7 @@ export const TREND_SLOT_DESCRIPTOR_META: Record<
config: { absoluteFloor: 2, relativeFloor: 0.02, decimals: 0 },
},
weight: {
- unit: "kg",
+ unitType: "WEIGHT",
config: { absoluteFloor: 0.3, relativeFloor: 0.01, decimals: 1 },
},
pulse: {
@@ -186,7 +195,7 @@ export const TREND_SLOT_DESCRIPTOR_META: Record<
config: { absoluteFloor: 1, relativeFloor: 0.1, decimals: 0 },
},
distance: {
- unit: "km",
+ unitType: "WALKING_RUNNING_DISTANCE",
config: { absoluteFloor: 0.3, relativeFloor: 0.05, decimals: 1 },
},
vo2_max: {
@@ -194,7 +203,7 @@ export const TREND_SLOT_DESCRIPTOR_META: Record<
config: { absoluteFloor: 0.5, relativeFloor: 0.02, decimals: 1 },
},
body_temp: {
- unit: "°C",
+ unitType: "BODY_TEMPERATURE",
config: { absoluteFloor: 0.2, relativeFloor: 0.005, decimals: 1 },
},
};
@@ -231,9 +240,12 @@ export interface TrendDescriptorCopy {
export function numericDescriptorCopy(
metric: string,
descriptor: TrendDescriptor,
+ /** Resolves a `unitType` slot's unit under the reader's preference. */
+ unitFor: (type: string) => string,
): TrendDescriptorCopy | null {
const meta = TREND_SLOT_DESCRIPTOR_META[metric];
if (!meta) return null;
+ const unit = meta.unitType ? unitFor(meta.unitType) : (meta.unit ?? "");
// Signed, formatted magnitude (e.g. "+8", "-1.4"). `stable` still
// carries its small delta so the caption can read "stable (±0 …)" if a
@@ -255,7 +267,7 @@ export function numericDescriptorCopy(
// reads "({delta}{unit})" → "(+8 mmHg)" with a unit, "(+1200)"
// without one — no dangling space inside the parens for unit-less
// metrics (steps, flights).
- unit: meta.unit ? ` ${meta.unit}` : "",
+ unit: unit ? ` ${unit}` : "",
},
};
}