mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
fix(units): keep converted values on the rounding helpers
The narrative, the workout distance and the elevation chart converted through the unrounded escape hatch, and the trend caption scaled its noise floor off the raw factor. They now take the rounded helpers, so a narrated pound or mmol/L figure reads at the grain every screen shows it, and the caption's floors are declared canonically (the distance floor in metres). The trends row joins the chart-feeding surfaces that may hand a raw scale to a chart. Refs #1067
This commit is contained in:
1 parent
0039bd1d1f
commit
9a282ed698
7 files changed
+33
-24
No files matched your search
@@ -52,7 +52,7 @@ const REGISTRY = "lib/measurements/display-transform.ts";
|
||||
const UNROUNDED_IMPORTERS = ["lib/targets/target-unit-display.ts"];
|
||||
|
||||
/**
|
||||
* The surfaces allowed to read a transform's raw `factor` / `offset`. All four
|
||||
* The surfaces allowed to read a transform's raw `factor` / `offset`. All five
|
||||
* do it for the same reason: the chart takes the multiplier as a prop, folds
|
||||
* it into its own series at the single read boundary, and formats the result
|
||||
* itself. Everything those files RENDER directly goes through the rounding
|
||||
@@ -61,6 +61,9 @@ const UNROUNDED_IMPORTERS = ["lib/targets/target-unit-display.ts"];
|
||||
*/
|
||||
const RAW_SCALE_SURFACES = [
|
||||
"app/page-client.tsx",
|
||||
// The insights trends row: a single transformed series hands its scale
|
||||
// and offset to the mini chart, which formats every value itself.
|
||||
"components/insights/trends-row.tsx",
|
||||
"app/insights/weight/page.tsx",
|
||||
"components/insights/healthkit-metric-page.tsx",
|
||||
"components/insights/device-score-tile.tsx",
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useUnitDisplay } from "@/hooks/use-unit-display";
|
||||
import {
|
||||
applyDisplayTransformUnrounded,
|
||||
applyDisplayTransform,
|
||||
getQuantityTransform,
|
||||
} from "@/lib/measurements/display-transform";
|
||||
|
||||
@@ -41,8 +41,8 @@ export function WorkoutElevationChart({
|
||||
const distance = getQuantityTransform("distance", preference);
|
||||
const elevation = getQuantityTransform("elevation", preference);
|
||||
const data = points.map((p) => ({
|
||||
dist: applyDisplayTransformUnrounded(p.distanceM, distance),
|
||||
alt: applyDisplayTransformUnrounded(p.altitude, elevation),
|
||||
dist: applyDisplayTransform(p.distanceM, distance),
|
||||
alt: applyDisplayTransform(p.altitude, elevation),
|
||||
}));
|
||||
|
||||
return (
|
||||
|
||||
@@ -265,7 +265,7 @@ export function TrendDescriptorCaption({
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { toDisplay, transformFor, unitFor } = useUnitDisplay();
|
||||
const { toDisplay, toDisplayDelta, unitFor } = useUnitDisplay();
|
||||
|
||||
const isMood = kind === "mood";
|
||||
const primaryType = types[0] ?? "";
|
||||
@@ -334,19 +334,21 @@ export function TrendDescriptorCaption({
|
||||
|
||||
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).
|
||||
// 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
|
||||
: meta?.unitType
|
||||
? {
|
||||
...meta.config,
|
||||
absoluteFloor:
|
||||
meta.config.absoluteFloor * transformFor(meta.unitType).factor,
|
||||
absoluteFloor: toDisplayDelta(
|
||||
meta.unitType,
|
||||
meta.config.absoluteFloor,
|
||||
),
|
||||
}
|
||||
: meta?.config;
|
||||
return computeTrendDescriptor(points, config);
|
||||
}, [points, isMood, metric, transformFor]);
|
||||
}, [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
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { getNumberFormat, getDateTimeFormat } from "@/lib/intl/formatter-cache";
|
||||
import {
|
||||
applyDisplayTransform,
|
||||
applyDisplayTransformUnrounded,
|
||||
getQuantityTransform,
|
||||
paceSecondsPerDistanceUnit,
|
||||
type UnitPreference,
|
||||
@@ -46,7 +45,7 @@ export function formatDistance(
|
||||
preference: UnitPreference,
|
||||
): string {
|
||||
const transform = getQuantityTransform("distance", preference);
|
||||
const value = applyDisplayTransformUnrounded(meters, transform);
|
||||
const value = applyDisplayTransform(meters, transform);
|
||||
const formatted = getNumberFormat(locale, {
|
||||
maximumFractionDigits: 2,
|
||||
minimumFractionDigits: value < 10 ? 2 : 1,
|
||||
|
||||
@@ -156,15 +156,15 @@ describe("assemblePeriodNarrativeContext — the reader's units", () => {
|
||||
);
|
||||
const g = ctx.metricDeltas.find((d) => d.type === "BLOOD_GLUCOSE")!;
|
||||
expect(g.unit).toBe("mmol/L");
|
||||
expect(g.current).toBe(5.99);
|
||||
expect(g.prior).toBe(4.99);
|
||||
expect(g.current).toBe(6);
|
||||
expect(g.prior).toBe(5);
|
||||
expect(g.delta).toBe(1);
|
||||
// Percent is unit-free.
|
||||
expect(g.deltaPercent).toBe(20);
|
||||
const w = ctx.metricDeltas.find((d) => d.type === "WEIGHT")!;
|
||||
expect(w.unit).toBe("lb");
|
||||
expect(w.current).toBe(180.78);
|
||||
expect(w.delta).toBe(4.41);
|
||||
expect(w.current).toBe(180.8);
|
||||
expect(w.delta).toBe(4.4);
|
||||
|
||||
const prose = buildDeterministicNarrative(ctx, "en");
|
||||
expect(prose).toContain("mmol/L");
|
||||
@@ -172,7 +172,7 @@ describe("assemblePeriodNarrativeContext — the reader's units", () => {
|
||||
expect(prose).not.toContain("mg/dL");
|
||||
expect(prose).not.toContain("kg");
|
||||
const prompt = buildNarrativeUserPrompt(ctx, "en");
|
||||
expect(prompt).toContain("BLOOD_GLUCOSE: 5.99 mmol/L");
|
||||
expect(prompt).toContain("BLOOD_GLUCOSE: 6 mmol/L");
|
||||
expect(prompt).not.toContain("mg/dL");
|
||||
});
|
||||
|
||||
@@ -231,10 +231,10 @@ describe("assemblePeriodNarrativeContext — the reader's units", () => {
|
||||
);
|
||||
const t = ctx.bandTransitions.find((b) => b.type === "BLOOD_GLUCOSE")!;
|
||||
expect(t.unit).toBe("mmol/L");
|
||||
expect(t.center).toBe(7.77);
|
||||
expect(t.center).toBe(7.8);
|
||||
expect(t.movedOut).toBe(true);
|
||||
expect(buildNarrativeUserPrompt(ctx, "en")).toContain(
|
||||
"BLOOD_GLUCOSE: 7.77 mmol/L",
|
||||
"BLOOD_GLUCOSE: 7.8 mmol/L",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,7 +57,8 @@ import { assembleDiscoveryMatrix } from "@/lib/insights/discovery-matrix";
|
||||
import { resolveModuleMap } from "@/lib/modules/gate";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import {
|
||||
applyDisplayTransformUnrounded,
|
||||
applyDisplayTransform,
|
||||
applyDisplayTransformDelta,
|
||||
getReadingTransform,
|
||||
isUnitSensitiveType,
|
||||
resolveUnitPreferences,
|
||||
@@ -135,8 +136,10 @@ function narratedUnit(
|
||||
const transform = getReadingTransform(type, units);
|
||||
return {
|
||||
unit: transform.displayUnit,
|
||||
abs: (v) => round2(applyDisplayTransformUnrounded(v, transform)),
|
||||
delta: (v) => round2(v * transform.factor),
|
||||
// Rounded to the unit's own grain (one decimal for lb and mmol/L), the
|
||||
// grain every screen shows the same reading at.
|
||||
abs: (v) => round2(applyDisplayTransform(v, transform)),
|
||||
delta: (v) => round2(applyDisplayTransformDelta(v, transform)),
|
||||
};
|
||||
}
|
||||
if (type === "SLEEP_DURATION") {
|
||||
|
||||
@@ -148,7 +148,8 @@ export interface TrendSlotDescriptorMeta {
|
||||
/**
|
||||
* 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.
|
||||
* series and the noise floor through) before describing it. Such a slot
|
||||
* declares its `absoluteFloor` in the CANONICAL unit (kg, m, °C).
|
||||
*/
|
||||
unitType?: string;
|
||||
config: TrendDescriptorConfig;
|
||||
@@ -196,7 +197,8 @@ export const TREND_SLOT_DESCRIPTOR_META: Record<
|
||||
},
|
||||
distance: {
|
||||
unitType: "WALKING_RUNNING_DISTANCE",
|
||||
config: { absoluteFloor: 0.3, relativeFloor: 0.05, decimals: 1 },
|
||||
// 300 m — canonical metres, converted to the reader's km or mi.
|
||||
config: { absoluteFloor: 300, relativeFloor: 0.05, decimals: 1 },
|
||||
},
|
||||
vo2_max: {
|
||||
unit: "",
|
||||
|
||||
Reference in new issue
Block a user