fix(insights): plot the trends row and log fevers in the reader's units

Two more surfaces carried a fixed unit. The insights trends row labelled
weight in kg and body temperature in °C for every reader, and plotted
walking distance as raw metres under a km label; its trend caption
said the same. A single transformed series there now takes unit, scale
and offset from the preference, and the caption describes the converted
series against a converted noise floor.

The illness day sheet and timeline showed and took a fever in °C. The
fever is now seeded, typed and shown in the reader's temperature unit
and stored in °C as before.

Refs #1067
This commit is contained in:
Marc-André Bombeck committed 2026-10-01 09:08:11 +02:00
1 parent d6f79c8bef
commit bc2d60cce8
16 files changed
+229 -38

No files matched your search

@@ -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"');
});
});
+17 -5
View File
@@ -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 (
+15 -2
View File
@@ -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}
/>