mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
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:
1 parent
d6f79c8bef
commit
bc2d60cce8
16 files changed
+229
-38
No files matched your search
+1
-1
@@ -9405,7 +9405,7 @@
|
||||
"severity": "Stärke {severity}",
|
||||
"noSymptoms": "Keine Symptome erfasst.",
|
||||
"impact": "Befinden: {impact}",
|
||||
"fever": "Temperatur: {value} °C",
|
||||
"fever": "Temperatur: {value} {unit}",
|
||||
"empty": "Noch keine Tage erfasst."
|
||||
},
|
||||
"loadError": "Diese Episode konnte nicht geladen werden.",
|
||||
|
||||
+1
-1
@@ -9405,7 +9405,7 @@
|
||||
"severity": "Severity {severity}",
|
||||
"noSymptoms": "No symptoms logged.",
|
||||
"impact": "How you functioned: {impact}",
|
||||
"fever": "Temperature: {value} °C",
|
||||
"fever": "Temperature: {value} {unit}",
|
||||
"empty": "No days logged yet."
|
||||
},
|
||||
"loadError": "Could not load this episode.",
|
||||
|
||||
+1
-1
@@ -9405,7 +9405,7 @@
|
||||
"severity": "Intensidad {severity}",
|
||||
"noSymptoms": "Sin síntomas registrados.",
|
||||
"impact": "Cómo funcionaste: {impact}",
|
||||
"fever": "Temperatura: {value} °C",
|
||||
"fever": "Temperatura: {value} {unit}",
|
||||
"empty": "Todavía no hay días registrados."
|
||||
},
|
||||
"loadError": "No se pudo cargar este episodio.",
|
||||
|
||||
+1
-1
@@ -9405,7 +9405,7 @@
|
||||
"severity": "Intensité {severity}",
|
||||
"noSymptoms": "Aucun symptôme enregistré.",
|
||||
"impact": "Votre forme : {impact}",
|
||||
"fever": "Température : {value} °C",
|
||||
"fever": "Température : {value} {unit}",
|
||||
"empty": "Aucun jour enregistré pour l’instant."
|
||||
},
|
||||
"loadError": "Impossible de charger cet épisode.",
|
||||
|
||||
+1
-1
@@ -9405,7 +9405,7 @@
|
||||
"severity": "Intensità {severity}",
|
||||
"noSymptoms": "Nessun sintomo registrato.",
|
||||
"impact": "Come ti sei sentito: {impact}",
|
||||
"fever": "Temperatura: {value} °C",
|
||||
"fever": "Temperatura: {value} {unit}",
|
||||
"empty": "Nessun giorno registrato finora."
|
||||
},
|
||||
"loadError": "Impossibile caricare questo episodio.",
|
||||
|
||||
+1
-1
@@ -9405,7 +9405,7 @@
|
||||
"severity": "심각도 {severity}",
|
||||
"noSymptoms": "기록한 증상이 없어요.",
|
||||
"impact": "활동 정도: {impact}",
|
||||
"fever": "체온: {value}°C",
|
||||
"fever": "체온: {value}{unit}",
|
||||
"empty": "아직 기록한 날이 없어요."
|
||||
},
|
||||
"loadError": "이 에피소드를 불러오지 못했어요.",
|
||||
|
||||
+1
-1
@@ -9405,7 +9405,7 @@
|
||||
"severity": "Nasilenie {severity}",
|
||||
"noSymptoms": "Nie zapisano objawów.",
|
||||
"impact": "Jak funkcjonowałeś: {impact}",
|
||||
"fever": "Temperatura: {value} °C",
|
||||
"fever": "Temperatura: {value} {unit}",
|
||||
"empty": "Nie zapisano jeszcze żadnych dni."
|
||||
},
|
||||
"loadError": "Nie udało się wczytać tego epizodu.",
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}` : "",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user