mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge pull request #629 from MBombeck/release/v1.32.26
release/v1.32.26 — apply the metric/imperial preference across every reading surface
This commit is contained in:
52 files changed
+1506
-247
No files matched your search
@@ -2,6 +2,35 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.32.26] — 2026-07-25
|
||||
|
||||
The metric and imperial display preference is now applied everywhere your
|
||||
weight, body composition, waist, and temperature readings appear. Until now the
|
||||
setting was saved but almost nothing read it, so the numbers stayed in
|
||||
kilograms and Celsius regardless of the choice. Thanks to a self-hoster who
|
||||
reported the gap and traced how the unit was being handled.
|
||||
|
||||
- **Weight and body composition follow the preference.** With imperial
|
||||
selected, weight, muscle mass, body water, bone mass, fat mass, and grip
|
||||
strength read in pounds across the dashboard tiles, the charts, the record
|
||||
list, the insight pages, and the coach read strip. Body temperature, skin and
|
||||
wrist temperature read in degrees Fahrenheit, and waist reads in inches.
|
||||
Storage is unchanged: every reading is still kept in canonical SI, and a
|
||||
metric user sees exactly the same numbers as before.
|
||||
- **Manual entry converts at the point of entry.** When you log or edit a value
|
||||
with imperial selected, the field is labelled in your unit and the number you
|
||||
type is converted back to canonical on save, so a pound reading is stored as
|
||||
the correct kilogram value rather than a mislabelled one.
|
||||
- **A logging tool can no longer store a foreign unit.** The measurement logging
|
||||
tool used by connected assistants now converts a recognised unit hint (such as
|
||||
pounds or Fahrenheit) to canonical, and refuses anything it does not
|
||||
recognise, so a reading is never saved under the wrong unit.
|
||||
- **Known remaining.** Height is entered in centimetres for now. Target and
|
||||
threshold panels also still read in kilograms and Celsius, so with imperial
|
||||
selected a weight chart reads in pounds while its target reference below it
|
||||
reads in kilograms. The stored values are correct either way; both are
|
||||
separate follow-ups.
|
||||
|
||||
## [1.32.25] — 2026-07-24
|
||||
|
||||
Mirrored medications are now marked as such, and the server limits how many
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.32.25
|
||||
version: 1.32.26
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
|
||||
+1
-1
@@ -1885,7 +1885,7 @@
|
||||
"levelShift": "Um {date} wurde eine Verschiebung des Niveaus erkannt.",
|
||||
"plateau": {
|
||||
"title": "Gewichts-Plateau auf aktueller Dosis",
|
||||
"evidence": "Gewichtsveränderung von {delta} kg in den letzten {window} Tagen ({readings} Messungen) — seit {days} Tagen auf {dose}."
|
||||
"evidence": "Gewichtsveränderung von {delta} {unit} in den letzten {window} Tagen ({readings} Messungen) — seit {days} Tagen auf {dose}."
|
||||
},
|
||||
"retarget": {
|
||||
"label": "Nicht der richtige Messwert?",
|
||||
|
||||
+1
-1
@@ -1875,7 +1875,7 @@
|
||||
"levelShift": "A shift in the level was detected around {date}.",
|
||||
"plateau": {
|
||||
"title": "Weight plateau on the current dose",
|
||||
"evidence": "Weight moved by {delta} kg over the last {window} days ({readings} readings) — {days} days on {dose}."
|
||||
"evidence": "Weight moved by {delta} {unit} over the last {window} days ({readings} readings) — {days} days on {dose}."
|
||||
},
|
||||
"retarget": {
|
||||
"label": "Not the right measurement?",
|
||||
|
||||
+1
-1
@@ -1887,7 +1887,7 @@
|
||||
"levelShift": "Se detectó un cambio de nivel en torno a {date}.",
|
||||
"plateau": {
|
||||
"title": "Meseta de peso con la dosis actual",
|
||||
"evidence": "Variación de peso de {delta} kg en los últimos {window} días ({readings} mediciones) — {days} días con {dose}."
|
||||
"evidence": "Variación de peso de {delta} {unit} en los últimos {window} días ({readings} mediciones) — {days} días con {dose}."
|
||||
},
|
||||
"retarget": {
|
||||
"label": "¿No es la medición correcta?",
|
||||
|
||||
+1
-1
@@ -1889,7 +1889,7 @@
|
||||
"levelShift": "Un changement de niveau a été détecté autour du {date}.",
|
||||
"plateau": {
|
||||
"title": "Plateau de poids à la dose actuelle",
|
||||
"evidence": "Variation de poids de {delta} kg sur les {window} derniers jours ({readings} mesures) — {days} jours sous {dose}."
|
||||
"evidence": "Variation de poids de {delta} {unit} sur les {window} derniers jours ({readings} mesures) — {days} jours sous {dose}."
|
||||
},
|
||||
"retarget": {
|
||||
"label": "Ce n'est pas la bonne mesure ?",
|
||||
|
||||
+1
-1
@@ -1879,7 +1879,7 @@
|
||||
"levelShift": "È stato rilevato un cambio di livello intorno al {date}.",
|
||||
"plateau": {
|
||||
"title": "Plateau del peso alla dose attuale",
|
||||
"evidence": "Variazione di peso di {delta} kg negli ultimi {window} giorni ({readings} misurazioni) — {days} giorni con {dose}."
|
||||
"evidence": "Variazione di peso di {delta} {unit} negli ultimi {window} giorni ({readings} misurazioni) — {days} giorni con {dose}."
|
||||
},
|
||||
"retarget": {
|
||||
"label": "Non è la misurazione giusta?",
|
||||
|
||||
+1
-1
@@ -1873,7 +1873,7 @@
|
||||
"levelShift": "Wykryto zmianę poziomu w okolicach {date}.",
|
||||
"plateau": {
|
||||
"title": "Plateau masy ciała przy obecnej dawce",
|
||||
"evidence": "Zmiana masy ciała o {delta} kg w ciągu ostatnich {window} dni ({readings} pomiarów) — {days} dni na dawce {dose}."
|
||||
"evidence": "Zmiana masy ciała o {delta} {unit} w ciągu ostatnich {window} dni ({readings} pomiarów) — {days} dni na dawce {dose}."
|
||||
},
|
||||
"retarget": {
|
||||
"label": "To nie ten pomiar?",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.32.25",
|
||||
"version": "1.32.26",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ try {
|
||||
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
|
||||
const CACHE_VERSION =
|
||||
(typeof self !== "undefined" && self.__APP_VERSION__) ||
|
||||
/* @sw-version-fallback */ "v1.32.25";
|
||||
/* @sw-version-fallback */ "v1.32.26";
|
||||
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
|
||||
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
|
||||
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Convert measurement rows stored in a RECOGNISED non-canonical unit to
|
||||
* canonical SI, in place. Companion to
|
||||
* `diagnose-noncanonical-measurement-units.ts` — run the diagnose first.
|
||||
*
|
||||
* SAFETY:
|
||||
* - Dry-run by default. Writes ONLY when called with `--execute`.
|
||||
* - Touches ONLY rows whose stored `unit` differs from the type's canonical
|
||||
* unit AND resolves through the shared alias resolver (lb/lbs/pound(s),
|
||||
* in/inch(es), °F, mmol/L). A row whose unit is already canonical, or whose
|
||||
* unit is unrecognised, is never touched.
|
||||
* - Each row is rewritten to the canonical `{ value, unit }` the same
|
||||
* resolver the CSV importer + MCP write path use produces, so the number is
|
||||
* re-expressed, never re-labelled in place (which would corrupt it).
|
||||
* - Affected users' measurement caches are busted at the end.
|
||||
*
|
||||
* This repairs the storage LABEL + value for the recognised-alias legacy rows
|
||||
* (the MCP-leak class). It cannot repair a row whose unit was already canonical
|
||||
* but whose value was a foreign unit — that class is not mechanically
|
||||
* identifiable (see the diagnose script).
|
||||
*
|
||||
* Run (inside the app container):
|
||||
* pnpm dlx tsx scripts/convert-noncanonical-measurement-units.ts # dry-run
|
||||
* pnpm dlx tsx scripts/convert-noncanonical-measurement-units.ts --execute # write
|
||||
*/
|
||||
import "dotenv/config";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getUnitForType } from "@/lib/validations/measurement";
|
||||
import { resolveToCanonicalUnit } from "@/lib/measurements/unit-aliases";
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const execute = process.argv.includes("--execute");
|
||||
|
||||
const groups = await prisma.measurement.groupBy({
|
||||
by: ["type", "unit"],
|
||||
_count: { _all: true },
|
||||
});
|
||||
const convertibleGroups = groups.filter((g) => {
|
||||
if (g.unit === null || g.unit === getUnitForType(g.type)) return false;
|
||||
return resolveToCanonicalUnit(g.type, 1, g.unit) !== null;
|
||||
});
|
||||
|
||||
if (convertibleGroups.length === 0) {
|
||||
console.log(
|
||||
"Nothing to convert — no recognised non-canonical units found.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const affectedUsers = new Set<string>();
|
||||
let converted = 0;
|
||||
|
||||
for (const g of convertibleGroups) {
|
||||
const canonical = getUnitForType(g.type);
|
||||
const rows = await prisma.measurement.findMany({
|
||||
where: { type: g.type, unit: g.unit },
|
||||
select: { id: true, userId: true, value: true, unit: true },
|
||||
});
|
||||
for (const row of rows) {
|
||||
const resolved = resolveToCanonicalUnit(
|
||||
g.type,
|
||||
row.value,
|
||||
row.unit as string,
|
||||
);
|
||||
if (!resolved) continue; // defensive; group already filtered
|
||||
converted += 1;
|
||||
affectedUsers.add(row.userId);
|
||||
console.log(
|
||||
` ${execute ? "convert" : "would convert"} ${g.type} ${row.id}: ` +
|
||||
`${row.value} ${row.unit} -> ${resolved.value} ${resolved.unit}`,
|
||||
);
|
||||
if (execute) {
|
||||
await prisma.measurement.update({
|
||||
where: { id: row.id },
|
||||
data: { value: resolved.value, unit: canonical },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (execute) {
|
||||
for (const userId of affectedUsers) {
|
||||
invalidateUserMeasurements(userId, { evict: true });
|
||||
}
|
||||
console.log(
|
||||
`\nConverted ${converted} row(s) across ${affectedUsers.size} user(s). ` +
|
||||
"Rollups recompute on the next read/backfill.",
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`\nDry run: ${converted} row(s) across ${affectedUsers.size} user(s) ` +
|
||||
"would be converted. Re-run with --execute to write.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Read-only diagnostic for measurement rows stored in a NON-canonical unit.
|
||||
*
|
||||
* Canonical storage is SI: every write path stamps `getUnitForType(type)`. The
|
||||
* one historical leak was the MCP `log_measurement` tool, which persisted a
|
||||
* caller-supplied `unit` verbatim (closed in v1.32.26). A row whose `unit`
|
||||
* column differs from its type's canonical unit is such a legacy row, and it
|
||||
* mislabels every display surface that trusts the column.
|
||||
*
|
||||
* This groups measurements by `(type, unit)` and prints every group whose
|
||||
* `unit` is not the canonical unit for its type, flagging whether the stored
|
||||
* unit is a RECOGNISED alias (mechanically convertible by the companion
|
||||
* convert script) or UNKNOWN (needs a manual look). Nothing is written.
|
||||
*
|
||||
* NOT auto-fixable and deliberately out of scope: a row whose stored unit IS
|
||||
* already canonical but whose VALUE was a foreign unit (e.g. a lb reading typed
|
||||
* into the old kg-labelled entry form). Those are indistinguishable from real
|
||||
* canonical readings; the reporter edits them via the record list.
|
||||
*
|
||||
* Run (inside the app container, which has DATABASE_URL):
|
||||
* pnpm dlx tsx scripts/diagnose-noncanonical-measurement-units.ts
|
||||
*/
|
||||
import "dotenv/config";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getUnitForType } from "@/lib/validations/measurement";
|
||||
import { resolveToCanonicalUnit } from "@/lib/measurements/unit-aliases";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const groups = await prisma.measurement.groupBy({
|
||||
by: ["type", "unit"],
|
||||
_count: { _all: true },
|
||||
});
|
||||
|
||||
const mismatches = groups.filter((g) => {
|
||||
if (g.unit === null) return false;
|
||||
return g.unit !== getUnitForType(g.type);
|
||||
});
|
||||
|
||||
if (mismatches.length === 0) {
|
||||
console.log("No non-canonical measurement units found. Storage is clean.");
|
||||
return;
|
||||
}
|
||||
|
||||
let convertible = 0;
|
||||
let unknown = 0;
|
||||
console.log(
|
||||
`Found ${mismatches.length} non-canonical (type, unit) group(s):\n`,
|
||||
);
|
||||
for (const g of mismatches.sort((a, b) => b._count._all - a._count._all)) {
|
||||
const canonical = getUnitForType(g.type);
|
||||
// Probe convertibility with a neutral value — only the recognition matters.
|
||||
const recognised = resolveToCanonicalUnit(g.type, 1, g.unit as string);
|
||||
const status = recognised ? "CONVERTIBLE" : "UNKNOWN — manual review";
|
||||
if (recognised) convertible += g._count._all;
|
||||
else unknown += g._count._all;
|
||||
console.log(
|
||||
` ${g.type}: stored="${g.unit}" canonical="${canonical}" ` +
|
||||
`rows=${g._count._all} [${status}]`,
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
`\nTotals: ${convertible} row(s) convertible, ${unknown} row(s) unknown.`,
|
||||
);
|
||||
console.log(
|
||||
"Convert the recognised aliases with:\n" +
|
||||
" pnpm dlx tsx scripts/convert-noncanonical-measurement-units.ts --execute",
|
||||
);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Structural guard (a tripwire, not a proof) for the metric/imperial unit
|
||||
* fix (issue #627). The dangerous failure mode is a PARTIAL fix: entry converts
|
||||
* but a display surface still hardcodes the canonical unit, so an imperial user
|
||||
* sees converted-then-mislabelled soup. This walks the dashboard + insight page
|
||||
* sources and asserts none of them passes a transformed type's DISPLAY UNIT
|
||||
* (kg / lb / cm / in / °C / °F / km/h / mph / km / mi) or a hardcoded value
|
||||
* scale as a literal prop — those must resolve from the user's preference
|
||||
* through `useUnitDisplay()` instead.
|
||||
*
|
||||
* Mutation check: add `unit="kg"` back to any swept page and this goes red.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { globSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
getDisplayTransform,
|
||||
TRANSFORMED_TYPES,
|
||||
} from "@/lib/measurements/display-transform";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
/** The set of display-unit strings a transformed type can render. */
|
||||
const TRANSFORMED_UNITS: ReadonlySet<string> = new Set(
|
||||
[...TRANSFORMED_TYPES].flatMap((type) => [
|
||||
getDisplayTransform(type, "metric").displayUnit,
|
||||
getDisplayTransform(type, "imperial").displayUnit,
|
||||
]),
|
||||
);
|
||||
|
||||
/** Swept surfaces: the dashboard client + every insights sub-page. */
|
||||
function sweptFiles(): string[] {
|
||||
return [
|
||||
join(SRC, "app", "page-client.tsx"),
|
||||
...globSync("app/insights/**/page.tsx", { cwd: SRC }).map((rel) =>
|
||||
join(SRC, rel),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
describe("unit-preference display guard", () => {
|
||||
it("sweeps a non-empty set of surfaces", () => {
|
||||
// Guards against a glob that silently matches nothing (a green void).
|
||||
expect(sweptFiles().length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
it("no swept surface hardcodes a transformed type's display unit", () => {
|
||||
const offenders: string[] = [];
|
||||
for (const file of sweptFiles()) {
|
||||
const src = readFileSync(file, "utf8");
|
||||
for (const unit of TRANSFORMED_UNITS) {
|
||||
// Match `unit="kg"` / `yAxisUnit="°C"` exactly (closing quote pinned so
|
||||
// `unit="kg/m²"` — the excluded BMI unit — never trips it).
|
||||
for (const prop of ["unit", "yAxisUnit"]) {
|
||||
if (src.includes(`${prop}="${unit}"`)) {
|
||||
offenders.push(`${file}: ${prop}="${unit}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("no insights page hardcodes a numeric valueScale literal", () => {
|
||||
// Transformed metrics resolve their scale from the transform; a literal
|
||||
// `valueScale={3.6}` is the pre-fix hand-rolled scaling that double-scales.
|
||||
const literal = /valueScale=\{\s*-?\d/;
|
||||
const offenders = sweptFiles().filter((file) =>
|
||||
literal.test(readFileSync(file, "utf8")),
|
||||
);
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("every transformed type carries both a metric and an imperial branch", () => {
|
||||
for (const type of TRANSFORMED_TYPES) {
|
||||
const metric = getDisplayTransform(type, "metric");
|
||||
const imperial = getDisplayTransform(type, "imperial");
|
||||
expect(metric.displayUnit).toBeTruthy();
|
||||
expect(imperial.displayUnit).toBeTruthy();
|
||||
expect(Number.isFinite(metric.factor)).toBe(true);
|
||||
expect(Number.isFinite(imperial.factor)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -22,8 +22,6 @@ export default function InsightsBodyTemperaturePage() {
|
||||
i18nPrefix="insights.bodyTemperature"
|
||||
explainerMetric="bodyTemperature"
|
||||
color="var(--chart-3)"
|
||||
unit="°C"
|
||||
yAxisUnit="°C"
|
||||
emptyStateIcon={<Thermometer className="size-6" />}
|
||||
emptyStateCtaType="BODY_TEMPERATURE"
|
||||
coachPrefill="I haven't logged any body temperature yet — what's the healthy range, and what should I do if it drifts?"
|
||||
|
||||
@@ -22,8 +22,6 @@ export default function InsightsKoerperwasserPage() {
|
||||
i18nPrefix="insights.totalBodyWater"
|
||||
explainerMetric="bodyWater"
|
||||
color="var(--info)"
|
||||
unit="kg"
|
||||
yAxisUnit="kg"
|
||||
emptyStateIcon={<Droplet className="size-6" />}
|
||||
emptyStateCtaType={null}
|
||||
captureType="TOTAL_BODY_WATER"
|
||||
|
||||
@@ -22,8 +22,6 @@ export default function InsightsKnochenmassePage() {
|
||||
i18nPrefix="insights.boneMass"
|
||||
explainerMetric="boneMass"
|
||||
color="var(--warning)"
|
||||
unit="kg"
|
||||
yAxisUnit="kg"
|
||||
emptyStateIcon={<Bone className="size-6" />}
|
||||
emptyStateCtaType={null}
|
||||
captureType="BONE_MASS"
|
||||
|
||||
@@ -22,8 +22,6 @@ export default function InsightsFettfreieMassePage() {
|
||||
i18nPrefix="insights.fatFreeMass"
|
||||
explainerMetric="fatFreeMass"
|
||||
color="var(--chart-1)"
|
||||
unit="kg"
|
||||
yAxisUnit="kg"
|
||||
emptyStateIcon={<Scale className="size-6" />}
|
||||
emptyStateCtaType={null}
|
||||
coachPrefill="I haven't logged any fat-free mass yet — what does this metric tell me about my health, and how do I improve it?"
|
||||
|
||||
@@ -22,8 +22,6 @@ export default function InsightsFettmassePage() {
|
||||
i18nPrefix="insights.fatMass"
|
||||
explainerMetric="fatMass"
|
||||
color="var(--warning)"
|
||||
unit="kg"
|
||||
yAxisUnit="kg"
|
||||
emptyStateIcon={<Droplets className="size-6" />}
|
||||
emptyStateCtaType={null}
|
||||
coachPrefill="I haven't logged any fat mass yet — what does this metric tell me about my health, and how do I improve it?"
|
||||
|
||||
@@ -23,8 +23,6 @@ export default function InsightsGripStrengthPage() {
|
||||
chartKey="gripStrength"
|
||||
i18nPrefix="insights.gripStrength"
|
||||
color="var(--success)"
|
||||
unit="kg"
|
||||
yAxisUnit="kg"
|
||||
statIcon={Dumbbell}
|
||||
emptyStateIcon={<Dumbbell className="size-6" />}
|
||||
emptyStateCtaType="GRIP_STRENGTH"
|
||||
|
||||
@@ -22,8 +22,6 @@ export default function InsightsMagermassePage() {
|
||||
i18nPrefix="insights.leanBodyMass"
|
||||
explainerMetric="leanBodyMass"
|
||||
color="var(--chart-1)"
|
||||
unit="kg"
|
||||
yAxisUnit="kg"
|
||||
emptyStateIcon={<Scale className="size-6" />}
|
||||
emptyStateCtaType={null}
|
||||
coachPrefill="I haven't logged any lean body mass yet — what does this metric tell me about my health, and how do I improve it?"
|
||||
|
||||
@@ -22,8 +22,6 @@ export default function InsightsMuskelmassePage() {
|
||||
i18nPrefix="insights.muscleMass"
|
||||
explainerMetric="muscleMass"
|
||||
color="var(--chart-1)"
|
||||
unit="kg"
|
||||
yAxisUnit="kg"
|
||||
emptyStateIcon={<Dumbbell className="size-6" />}
|
||||
emptyStateCtaType={null}
|
||||
coachPrefill="I haven't logged any muscle mass yet — what does this metric tell me about my health, and how do I improve it?"
|
||||
|
||||
@@ -30,8 +30,6 @@ export default function InsightsHauttemperaturPage() {
|
||||
i18nPrefix="insights.skinTemperature"
|
||||
explainerMetric="skinTemperature"
|
||||
color="var(--warning)"
|
||||
unit="°C"
|
||||
yAxisUnit="°C"
|
||||
emptyStateIcon={<Thermometer className="size-6" />}
|
||||
emptyStateCtaType={null}
|
||||
coachPrefill="I haven't logged any skin temperature yet — what does this metric tell me about my health, and how do I improve it?"
|
||||
@@ -41,7 +39,6 @@ export default function InsightsHauttemperaturPage() {
|
||||
title={t("measurements.typeBodyTemperatureDeviation")}
|
||||
icon={ThermometerSnowflake}
|
||||
color="var(--info)"
|
||||
unit="°C"
|
||||
fractionDigits={2}
|
||||
sectionTitle={t("insights.bodyTempDeviation.title")}
|
||||
sectionIcon={ThermometerSnowflake}
|
||||
|
||||
@@ -22,8 +22,6 @@ export default function InsightsWaistPage() {
|
||||
chartKey="waistCircumference"
|
||||
i18nPrefix="insights.waist"
|
||||
color="var(--info)"
|
||||
unit="cm"
|
||||
yAxisUnit="cm"
|
||||
statIcon={Ruler}
|
||||
emptyStateIcon={<Ruler className="size-6" />}
|
||||
emptyStateCtaType="WAIST_CIRCUMFERENCE"
|
||||
|
||||
@@ -22,10 +22,6 @@ export default function InsightsGehstreckePage() {
|
||||
i18nPrefix="insights.walkingRunningDistance"
|
||||
explainerMetric="walkingDistance"
|
||||
color="var(--success)"
|
||||
unit="km"
|
||||
yAxisUnit="km"
|
||||
// Stored in metres; display daily totals in km (1 m = 0.001 km).
|
||||
valueScale={0.001}
|
||||
emptyStateIcon={<Footprints className="size-6" />}
|
||||
emptyStateCtaType={null}
|
||||
coachPrefill="I haven't logged any walking and running distance yet — what does this metric tell me about my health, and how do I improve it?"
|
||||
|
||||
@@ -22,9 +22,6 @@ export default function InsightsGehgeschwindigkeitPage() {
|
||||
i18nPrefix="insights.walkingSpeed"
|
||||
explainerMetric="walkingSpeed"
|
||||
color="var(--success)"
|
||||
unit="km/h"
|
||||
yAxisUnit="km/h"
|
||||
valueScale={3.6}
|
||||
emptyStateIcon={<Gauge className="size-6" />}
|
||||
emptyStateCtaType={null}
|
||||
coachPrefill="I haven't logged any walking speed yet — what does this metric tell me about my health, and how do I improve it?"
|
||||
|
||||
@@ -4,6 +4,7 @@ import Link from "next/link";
|
||||
import { Scale } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useUnitDisplay } from "@/hooks/use-unit-display";
|
||||
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
|
||||
@@ -36,10 +37,38 @@ export default function InsightsGewichtPage() {
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated);
|
||||
const unitDisplay = useUnitDisplay();
|
||||
|
||||
const { data: analytics, isEmpty } = useInsightsAnalytics("WEIGHT");
|
||||
const weightSummary = analytics?.summaries?.WEIGHT ?? null;
|
||||
|
||||
// v1.32.26 — resolve the weight display unit + scale from the user's
|
||||
// preference (kg for metric, lb for imperial). This page inlines the shell
|
||||
// rather than riding the scaffold, so it converts the stat strip + chart
|
||||
// series + bands here. Weight has no affine offset, so the factor alone
|
||||
// covers both the values and the bands.
|
||||
const weightTransform = unitDisplay.transformFor("WEIGHT");
|
||||
const weightUnit = weightTransform.displayUnit;
|
||||
const weightScale = weightTransform.factor;
|
||||
const displaySummary =
|
||||
weightSummary && weightScale !== 1
|
||||
? {
|
||||
...weightSummary,
|
||||
min:
|
||||
weightSummary.min === null ? null : weightSummary.min * weightScale,
|
||||
max:
|
||||
weightSummary.max === null ? null : weightSummary.max * weightScale,
|
||||
mean:
|
||||
weightSummary.mean === null
|
||||
? null
|
||||
: weightSummary.mean * weightScale,
|
||||
median:
|
||||
weightSummary.median === null
|
||||
? null
|
||||
: weightSummary.median * weightScale,
|
||||
}
|
||||
: weightSummary;
|
||||
|
||||
// v1.12.8 — visible-range stats shared between the chart and the strip.
|
||||
const { statsByType, onVisibleStats } = useChartDomainStats();
|
||||
|
||||
@@ -73,6 +102,16 @@ export default function InsightsGewichtPage() {
|
||||
upperBound: 250,
|
||||
})
|
||||
: undefined;
|
||||
// The bands are kg bounds → scale into the display unit so the shaded zone
|
||||
// tracks the converted line (factor-only; weight carries no offset).
|
||||
const displayBands =
|
||||
weightBands && weightScale !== 1
|
||||
? weightBands.map((band) => ({
|
||||
...band,
|
||||
min: band.min * weightScale,
|
||||
max: band.max * weightScale,
|
||||
}))
|
||||
: weightBands;
|
||||
|
||||
return (
|
||||
<SubPageShell
|
||||
@@ -81,14 +120,14 @@ export default function InsightsGewichtPage() {
|
||||
explainerMetric="weight"
|
||||
statStrip={
|
||||
<MetricStatStrip
|
||||
summary={weightSummary}
|
||||
unit="kg"
|
||||
summary={displaySummary}
|
||||
unit={weightUnit}
|
||||
seriesLabel={t("insights.weightSectionTitle")}
|
||||
icon={Scale}
|
||||
windowStats={statsByType?.WEIGHT ?? null}
|
||||
/>
|
||||
}
|
||||
coachReadStrip={<CoachReadStrip metricType="WEIGHT" unit="kg" />}
|
||||
coachReadStrip={<CoachReadStrip metricType="WEIGHT" unit={weightUnit} />}
|
||||
diversityNudge={
|
||||
<MeasurementDiversityNudge
|
||||
measurementType="WEIGHT"
|
||||
@@ -106,10 +145,11 @@ export default function InsightsGewichtPage() {
|
||||
title={t("charts.weight")}
|
||||
titleIcon={Scale}
|
||||
colors={["var(--chart-1)"]}
|
||||
unit="kg"
|
||||
valueBands={weightBands}
|
||||
unit={weightUnit}
|
||||
valueBands={displayBands}
|
||||
compareBaseline={compareBaseline}
|
||||
userTimezone={user?.timezone}
|
||||
valueScale={weightScale}
|
||||
onVisibleStats={onVisibleStats}
|
||||
/>
|
||||
|
||||
|
||||
@@ -22,8 +22,6 @@ export default function InsightsWristTemperaturePage() {
|
||||
i18nPrefix="insights.wristTemperature"
|
||||
explainerMetric="wristTemperature"
|
||||
color="var(--warning)"
|
||||
unit="°C"
|
||||
yAxisUnit="°C"
|
||||
emptyStateIcon={<Thermometer className="size-6" />}
|
||||
emptyStateCtaType={null}
|
||||
coachPrefill="I haven't logged any wrist-temperature data yet — what does this metric tell me about my health, and how do I improve it?"
|
||||
|
||||
+96
-36
@@ -3,6 +3,7 @@
|
||||
import React, { Suspense, useCallback, useMemo, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useUnitDisplay } from "@/hooks/use-unit-display";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
|
||||
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
|
||||
@@ -225,6 +226,18 @@ export default function DashboardPageClient({
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const queryClient = useQueryClient();
|
||||
// v1.32.26 — metric/imperial display choke point for the dashboard tiles +
|
||||
// weight chart. `td` converts an ABSOLUTE reading (affine, so a temperature
|
||||
// picks up the °F offset); `tdd` converts a DELTA / slope (factor only, so a
|
||||
// Δ never inherits the offset). Metric users take the identity path.
|
||||
const unitDisplay = useUnitDisplay();
|
||||
const td = (type: string, value: number | null | undefined): number | null =>
|
||||
value == null ? null : unitDisplay.toDisplay(type, value);
|
||||
const tdd = (
|
||||
type: string,
|
||||
value: number | null | undefined,
|
||||
): number | null =>
|
||||
value == null ? null : unitDisplay.toDisplayDelta(type, value);
|
||||
const [quickEntryDialog, setQuickEntryDialog] =
|
||||
useState<QuickEntryDialog>(null);
|
||||
|
||||
@@ -713,7 +726,29 @@ export default function DashboardPageClient({
|
||||
const bands = serverBands ?? clientBands;
|
||||
const bpTargets = bands.bpTargets;
|
||||
const weightRange = bands.weightRange;
|
||||
// The range hint tooltip prints its band bounds in the display unit, so scale
|
||||
// the kg thresholds by the weight factor (offset-free). The colour-classing
|
||||
// below stays in canonical kg — it compares a canonical avg against the
|
||||
// canonical range, so both sides remain in the same unit.
|
||||
const weightHintRange = weightRange
|
||||
? {
|
||||
greenMin: unitDisplay.toDisplay("WEIGHT", weightRange.greenMin),
|
||||
greenMax: unitDisplay.toDisplay("WEIGHT", weightRange.greenMax),
|
||||
orangeMin: unitDisplay.toDisplay("WEIGHT", weightRange.orangeMin),
|
||||
orangeMax: unitDisplay.toDisplay("WEIGHT", weightRange.orangeMax),
|
||||
}
|
||||
: weightRange;
|
||||
const weightBands = bands.weightBands ?? undefined;
|
||||
// Scale the kg chart bands into the display unit so the shaded zone tracks
|
||||
// the converted line (factor-only; weight has no offset). Metric = identity.
|
||||
const weightDisplayBands =
|
||||
weightBands && unitDisplay.preference === "imperial"
|
||||
? weightBands.map((band) => ({
|
||||
...band,
|
||||
min: unitDisplay.toDisplay("WEIGHT", band.min),
|
||||
max: unitDisplay.toDisplay("WEIGHT", band.max),
|
||||
}))
|
||||
: weightBands;
|
||||
const bpTargetZones = bpTargets
|
||||
? [
|
||||
{
|
||||
@@ -897,10 +932,10 @@ export default function DashboardPageClient({
|
||||
<TrendCard
|
||||
key="weight"
|
||||
label={t("dashboard.weightShort")}
|
||||
latest={w?.latest ?? null}
|
||||
unit="kg"
|
||||
avg7={w?.avg7 ?? null}
|
||||
avg30={w?.avg30 ?? null}
|
||||
latest={td("WEIGHT", w?.latest)}
|
||||
unit={unitDisplay.unitFor("WEIGHT")}
|
||||
avg7={td("WEIGHT", w?.avg7)}
|
||||
avg30={td("WEIGHT", w?.avg30)}
|
||||
avg7ColorClass={getRangeColorClass(w?.avg7, {
|
||||
range: weightRange,
|
||||
})}
|
||||
@@ -908,23 +943,23 @@ export default function DashboardPageClient({
|
||||
range: weightRange,
|
||||
})}
|
||||
avg7Hint={getRangeHint(
|
||||
"kg",
|
||||
{ range: weightRange },
|
||||
unitDisplay.unitFor("WEIGHT"),
|
||||
{ range: weightHintRange },
|
||||
t,
|
||||
fmt.number,
|
||||
)}
|
||||
avg30Hint={getRangeHint(
|
||||
"kg",
|
||||
{ range: weightRange },
|
||||
unitDisplay.unitFor("WEIGHT"),
|
||||
{ range: weightHintRange },
|
||||
t,
|
||||
fmt.number,
|
||||
)}
|
||||
slope30={w?.slope30 ?? null}
|
||||
trend7Delta={summaryToTrend7Delta(w)}
|
||||
trend7Delta={tdd("WEIGHT", summaryToTrend7Delta(w))}
|
||||
icon={Activity}
|
||||
directionSentiment="up-bad"
|
||||
compareBaseline={compareBaseline}
|
||||
compareDelta={tileCompareDelta(w)}
|
||||
compareDelta={tdd("WEIGHT", tileCompareDelta(w))}
|
||||
staleDays={tileStaleDays("WEIGHT")}
|
||||
/>
|
||||
),
|
||||
@@ -1276,16 +1311,22 @@ export default function DashboardPageClient({
|
||||
<TrendCard
|
||||
key="wristTemperature"
|
||||
label={t("measurements.typeWristTemperature")}
|
||||
latest={wristTempSummary?.latest ?? null}
|
||||
unit="°C"
|
||||
avg7={wristTempSummary?.avg7 ?? null}
|
||||
avg30={wristTempSummary?.avg30 ?? null}
|
||||
latest={td("WRIST_TEMPERATURE", wristTempSummary?.latest)}
|
||||
unit={unitDisplay.unitFor("WRIST_TEMPERATURE")}
|
||||
avg7={td("WRIST_TEMPERATURE", wristTempSummary?.avg7)}
|
||||
avg30={td("WRIST_TEMPERATURE", wristTempSummary?.avg30)}
|
||||
slope30={wristTempSummary?.slope30 ?? null}
|
||||
trend7Delta={summaryToTrend7Delta(wristTempSummary)}
|
||||
trend7Delta={tdd(
|
||||
"WRIST_TEMPERATURE",
|
||||
summaryToTrend7Delta(wristTempSummary),
|
||||
)}
|
||||
icon={Thermometer}
|
||||
directionSentiment="neutral"
|
||||
compareBaseline={compareBaseline}
|
||||
compareDelta={tileCompareDelta(wristTempSummary)}
|
||||
compareDelta={tdd(
|
||||
"WRIST_TEMPERATURE",
|
||||
tileCompareDelta(wristTempSummary),
|
||||
)}
|
||||
staleDays={tileStaleDays("WRIST_TEMPERATURE")}
|
||||
/>
|
||||
),
|
||||
@@ -1302,16 +1343,22 @@ export default function DashboardPageClient({
|
||||
<TrendCard
|
||||
key="muscleMass"
|
||||
label={t("measurements.typeMuscleMass")}
|
||||
latest={muscleMassSummary?.latest ?? null}
|
||||
unit="kg"
|
||||
avg7={muscleMassSummary?.avg7 ?? null}
|
||||
avg30={muscleMassSummary?.avg30 ?? null}
|
||||
latest={td("MUSCLE_MASS", muscleMassSummary?.latest)}
|
||||
unit={unitDisplay.unitFor("MUSCLE_MASS")}
|
||||
avg7={td("MUSCLE_MASS", muscleMassSummary?.avg7)}
|
||||
avg30={td("MUSCLE_MASS", muscleMassSummary?.avg30)}
|
||||
slope30={muscleMassSummary?.slope30 ?? null}
|
||||
trend7Delta={summaryToTrend7Delta(muscleMassSummary)}
|
||||
trend7Delta={tdd(
|
||||
"MUSCLE_MASS",
|
||||
summaryToTrend7Delta(muscleMassSummary),
|
||||
)}
|
||||
icon={Dumbbell}
|
||||
directionSentiment="up-good"
|
||||
compareBaseline={compareBaseline}
|
||||
compareDelta={tileCompareDelta(muscleMassSummary)}
|
||||
compareDelta={tdd(
|
||||
"MUSCLE_MASS",
|
||||
tileCompareDelta(muscleMassSummary),
|
||||
)}
|
||||
staleDays={tileStaleDays("MUSCLE_MASS")}
|
||||
/>
|
||||
),
|
||||
@@ -1325,16 +1372,22 @@ export default function DashboardPageClient({
|
||||
<TrendCard
|
||||
key="totalBodyWater"
|
||||
label={t("measurements.typeTotalBodyWater")}
|
||||
latest={bodyWaterSummary?.latest ?? null}
|
||||
unit="kg"
|
||||
avg7={bodyWaterSummary?.avg7 ?? null}
|
||||
avg30={bodyWaterSummary?.avg30 ?? null}
|
||||
latest={td("TOTAL_BODY_WATER", bodyWaterSummary?.latest)}
|
||||
unit={unitDisplay.unitFor("TOTAL_BODY_WATER")}
|
||||
avg7={td("TOTAL_BODY_WATER", bodyWaterSummary?.avg7)}
|
||||
avg30={td("TOTAL_BODY_WATER", bodyWaterSummary?.avg30)}
|
||||
slope30={bodyWaterSummary?.slope30 ?? null}
|
||||
trend7Delta={summaryToTrend7Delta(bodyWaterSummary)}
|
||||
trend7Delta={tdd(
|
||||
"TOTAL_BODY_WATER",
|
||||
summaryToTrend7Delta(bodyWaterSummary),
|
||||
)}
|
||||
icon={Droplet}
|
||||
directionSentiment="neutral"
|
||||
compareBaseline={compareBaseline}
|
||||
compareDelta={tileCompareDelta(bodyWaterSummary)}
|
||||
compareDelta={tdd(
|
||||
"TOTAL_BODY_WATER",
|
||||
tileCompareDelta(bodyWaterSummary),
|
||||
)}
|
||||
staleDays={tileStaleDays("TOTAL_BODY_WATER")}
|
||||
/>
|
||||
),
|
||||
@@ -1348,16 +1401,22 @@ export default function DashboardPageClient({
|
||||
<TrendCard
|
||||
key="boneMass"
|
||||
label={t("measurements.typeBoneMass")}
|
||||
latest={boneMassSummary?.latest ?? null}
|
||||
unit="kg"
|
||||
avg7={boneMassSummary?.avg7 ?? null}
|
||||
avg30={boneMassSummary?.avg30 ?? null}
|
||||
latest={td("BONE_MASS", boneMassSummary?.latest)}
|
||||
unit={unitDisplay.unitFor("BONE_MASS")}
|
||||
avg7={td("BONE_MASS", boneMassSummary?.avg7)}
|
||||
avg30={td("BONE_MASS", boneMassSummary?.avg30)}
|
||||
slope30={boneMassSummary?.slope30 ?? null}
|
||||
trend7Delta={summaryToTrend7Delta(boneMassSummary)}
|
||||
trend7Delta={tdd(
|
||||
"BONE_MASS",
|
||||
summaryToTrend7Delta(boneMassSummary),
|
||||
)}
|
||||
icon={Bone}
|
||||
directionSentiment="neutral"
|
||||
compareBaseline={compareBaseline}
|
||||
compareDelta={tileCompareDelta(boneMassSummary)}
|
||||
compareDelta={tdd(
|
||||
"BONE_MASS",
|
||||
tileCompareDelta(boneMassSummary),
|
||||
)}
|
||||
staleDays={tileStaleDays("BONE_MASS")}
|
||||
/>
|
||||
),
|
||||
@@ -1554,10 +1613,11 @@ export default function DashboardPageClient({
|
||||
types={["WEIGHT"]}
|
||||
title={t("dashboard.weight")}
|
||||
colors={["var(--chart-1)"]}
|
||||
unit="kg"
|
||||
valueBands={weightBands}
|
||||
unit={unitDisplay.unitFor("WEIGHT")}
|
||||
valueBands={weightDisplayBands}
|
||||
compareBaseline={compareBaseline}
|
||||
userTimezone={user?.timezone}
|
||||
valueScale={unitDisplay.transformFor("WEIGHT").factor}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -220,6 +220,15 @@ interface HealthChartProps {
|
||||
* uniformly.
|
||||
*/
|
||||
valueScale?: number;
|
||||
/**
|
||||
* v1.32.26 — display-time additive offset folded in alongside
|
||||
* `valueScale` at the same read boundary: `display = raw * valueScale +
|
||||
* valueOffset`. Required for the affine °C→°F conversion (a shift, not
|
||||
* just a scale). Defaults to `0`. Because every tooltip delta / trend
|
||||
* slope is computed as a DIFFERENCE of two offset values, the offset
|
||||
* cancels there automatically — a rate never inherits the +32 shift.
|
||||
*/
|
||||
valueOffset?: number;
|
||||
/**
|
||||
* v1.12.8 — chart-reactive metric statistics. When supplied, the chart
|
||||
* reports the per-type Min / Max / Median / Mean of the data currently
|
||||
@@ -586,6 +595,7 @@ export function HealthChart({
|
||||
verticalMarkers,
|
||||
userTimezone = "Europe/Berlin",
|
||||
valueScale = 1,
|
||||
valueOffset = 0,
|
||||
onVisibleStats,
|
||||
titleIcon,
|
||||
onDataReady,
|
||||
@@ -832,7 +842,7 @@ export function HealthChart({
|
||||
}>(`/api/measurements/series?${sleepParams}`);
|
||||
const points = data?.points ?? [];
|
||||
for (const point of points) {
|
||||
const value = point.value * valueScale;
|
||||
const value = point.value * valueScale + valueOffset;
|
||||
if (value == null || !Number.isFinite(value)) continue;
|
||||
const dayKey = toDayKey(point.at, dayKeyFormatter);
|
||||
const bucket = dailyAggregates.get(dayKey) ?? {
|
||||
@@ -927,7 +937,7 @@ export function HealthChart({
|
||||
// v1.15.20 — the BMI division moved into the query's `select`
|
||||
// callback so the cached series stays raw and the WEIGHT and
|
||||
// BMI views share one cache entry.
|
||||
const value = measurement.value * valueScale;
|
||||
const value = measurement.value * valueScale + valueOffset;
|
||||
|
||||
if (!Number.isFinite(value)) {
|
||||
continue;
|
||||
@@ -957,8 +967,8 @@ export function HealthChart({
|
||||
typeof measurement.minValue === "number" &&
|
||||
typeof measurement.maxValue === "number"
|
||||
) {
|
||||
const scaledMin = measurement.minValue * valueScale;
|
||||
const scaledMax = measurement.maxValue * valueScale;
|
||||
const scaledMin = measurement.minValue * valueScale + valueOffset;
|
||||
const scaledMax = measurement.maxValue * valueScale + valueOffset;
|
||||
current.min =
|
||||
current.min === null
|
||||
? scaledMin
|
||||
|
||||
@@ -1,6 +1,29 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
// The embedded crosstab reads the unit preference through `useUnitDisplay`,
|
||||
// which hangs off `useAuth` (a react-query hook). These are static SSR renders
|
||||
// with no QueryClient, so mock the hook to a real metric-preference impl.
|
||||
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,
|
||||
transformFor: forType,
|
||||
toDisplay: (t: string, v: number) =>
|
||||
dt.applyDisplayTransform(v, forType(t)),
|
||||
fromDisplay: (t: string, v: number) =>
|
||||
dt.invertDisplayTransform(v, forType(t)),
|
||||
toDisplayDelta: (t: string, v: number) =>
|
||||
dt.applyDisplayTransformDelta(v, forType(t)),
|
||||
unitFor: (t: string) => forType(t).displayUnit,
|
||||
decimalsFor: (t: string) => forType(t).decimals,
|
||||
isTransformed: (t: string) => dt.hasDisplayTransform(t),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { CalendarResponse } from "../types";
|
||||
import type { CycleInsightsResponse } from "../use-cycle";
|
||||
|
||||
@@ -1,6 +1,31 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
// The crosstab reads the unit preference through `useUnitDisplay`, which hangs
|
||||
// off `useAuth` (a react-query hook). These are static SSR renders with no
|
||||
// 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.
|
||||
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,
|
||||
transformFor: forType,
|
||||
toDisplay: (t: string, v: number) =>
|
||||
dt.applyDisplayTransform(v, forType(t)),
|
||||
fromDisplay: (t: string, v: number) =>
|
||||
dt.invertDisplayTransform(v, forType(t)),
|
||||
toDisplayDelta: (t: string, v: number) =>
|
||||
dt.applyDisplayTransformDelta(v, forType(t)),
|
||||
unitFor: (t: string) => forType(t).displayUnit,
|
||||
decimalsFor: (t: string) => forType(t).decimals,
|
||||
isTransformed: (t: string) => dt.hasDisplayTransform(t),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
CyclePhaseCrosstab,
|
||||
CyclePhaseHeadline,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useUnitDisplay } from "@/hooks/use-unit-display";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
@@ -77,6 +78,18 @@ function fmt(value: number, display: CyclePhaseCrosstabDisplay): string {
|
||||
return display === "steps" ? Math.round(value).toString() : value.toFixed(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.32.26 — the two displays whose values follow the metric/imperial
|
||||
* preference, mapped to the measurement type that carries the transform. Weight
|
||||
* is offset-free (kg→lb); temperature is affine (°C→°F), so an ABSOLUTE phase
|
||||
* average takes `toDisplay` while a phase DELTA takes `toDisplayDelta` (the
|
||||
* offset must never leak into a difference).
|
||||
*/
|
||||
const DISPLAY_TO_TYPE: Partial<Record<CyclePhaseCrosstabDisplay, string>> = {
|
||||
kg: "WEIGHT",
|
||||
celsius: "BODY_TEMPERATURE",
|
||||
};
|
||||
|
||||
const HEADLINE_KEY: Record<string, string> = {
|
||||
restingHeartRate: "cycle.insights.headline.restingHeartRate",
|
||||
heartRateVariability: "cycle.insights.headline.heartRateVariability",
|
||||
@@ -93,6 +106,7 @@ export function CyclePhaseHeadline({
|
||||
headline: CyclePhaseCrosstabRow | null;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const unitDisplay = useUnitDisplay();
|
||||
if (!headline) {
|
||||
return (
|
||||
<p
|
||||
@@ -108,8 +122,9 @@ export function CyclePhaseHeadline({
|
||||
);
|
||||
// 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 = unitKey ? t(unitKey) : "";
|
||||
const unit = mt ? unitDisplay.unitFor(mt) : unitKey ? t(unitKey) : "";
|
||||
const up = headline.delta >= 0;
|
||||
const dir = t(
|
||||
up
|
||||
@@ -118,7 +133,13 @@ export function CyclePhaseHeadline({
|
||||
);
|
||||
const params = {
|
||||
metric: metricLabel,
|
||||
delta: fmt(Math.abs(headline.delta), headline.display),
|
||||
// A phase delta → factor-only conversion (no affine offset).
|
||||
delta: fmt(
|
||||
Math.abs(
|
||||
mt ? unitDisplay.toDisplayDelta(mt, headline.delta) : headline.delta,
|
||||
),
|
||||
headline.display,
|
||||
),
|
||||
unit,
|
||||
dir,
|
||||
};
|
||||
@@ -141,6 +162,7 @@ export function CyclePhaseCrosstab({
|
||||
rows: CyclePhaseCrosstabRow[];
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const unitDisplay = useUnitDisplay();
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
return (
|
||||
@@ -155,15 +177,20 @@ export function CyclePhaseCrosstab({
|
||||
);
|
||||
// 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 = unitKey ? t(unitKey) : "";
|
||||
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;
|
||||
// `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
|
||||
// observational, so colouring it would imply a verdict the data
|
||||
// doesn't support. The sign prefix + `data-direction` carry it.
|
||||
const up = row.delta >= 0;
|
||||
const deltaText = `${up ? "+" : ""}${fmt(row.delta, row.display)} ${unit}`;
|
||||
const deltaText = `${up ? "+" : ""}${fmt(deltaConv(row.delta), row.display)} ${unit}`;
|
||||
return (
|
||||
<li
|
||||
key={row.metricKey}
|
||||
@@ -197,8 +224,8 @@ export function CyclePhaseCrosstab({
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("cycle.insights.crosstab.detail", {
|
||||
metric: metricLabel,
|
||||
lutealAvg: fmt(row.lutealAvg, row.display),
|
||||
follicularAvg: fmt(row.follicularAvg, row.display),
|
||||
lutealAvg: fmt(absConv(row.lutealAvg), row.display),
|
||||
follicularAvg: fmt(absConv(row.follicularAvg), row.display),
|
||||
unit,
|
||||
lutealDays: row.lutealDays,
|
||||
follicularDays: row.follicularDays,
|
||||
|
||||
@@ -1,6 +1,31 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
// The vitals tiles read the unit preference through `useUnitDisplay`, which
|
||||
// hangs off `useAuth` (a react-query hook). These are static SSR renders with
|
||||
// no QueryClient, so mock the hook to a real metric-preference implementation
|
||||
// — metric is the identity on values and yields the canonical display symbols
|
||||
// (e.g. °C), matching the pre-fix render the assertions expect.
|
||||
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,
|
||||
transformFor: forType,
|
||||
toDisplay: (t: string, v: number) =>
|
||||
dt.applyDisplayTransform(v, forType(t)),
|
||||
fromDisplay: (t: string, v: number) =>
|
||||
dt.invertDisplayTransform(v, forType(t)),
|
||||
toDisplayDelta: (t: string, v: number) =>
|
||||
dt.applyDisplayTransformDelta(v, forType(t)),
|
||||
unitFor: (t: string) => forType(t).displayUnit,
|
||||
decimalsFor: (t: string) => forType(t).decimals,
|
||||
isTransformed: (t: string) => dt.hasDisplayTransform(t),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type {
|
||||
DerivedMetricResponse,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { Sparkles } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useUnitDisplay } from "@/hooks/use-unit-display";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
@@ -60,6 +61,15 @@ export function CoachReadStrip({
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { t, locale } = useTranslations();
|
||||
const mounted = useMounted();
|
||||
// v1.32.26 — a type with a registered metric/imperial transform resolves its
|
||||
// display unit + conversion from the user's preference and IGNORES the
|
||||
// page-passed `unit` / `valueScale` (weight labelled "kg" would otherwise
|
||||
// never follow the toggle). The band edges + today's value are ABSOLUTE, so
|
||||
// the affine `toDisplay` (factor + offset) is correct here. Untransformed
|
||||
// metrics keep the props (glucose passes its own unit + reciprocal scale).
|
||||
const unitDisplay = useUnitDisplay();
|
||||
const transformed = unitDisplay.isTransformed(metricType);
|
||||
const resolvedUnit = transformed ? unitDisplay.unitFor(metricType) : unit;
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: queryKeys.insightsCoachRead(metricType),
|
||||
@@ -80,7 +90,11 @@ export function CoachReadStrip({
|
||||
new Intl.NumberFormat(locale, {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: fractionDigits,
|
||||
}).format(value * valueScale);
|
||||
}).format(
|
||||
transformed
|
||||
? unitDisplay.toDisplay(metricType, value)
|
||||
: value * valueScale,
|
||||
);
|
||||
|
||||
const baselineLine = ((): string | null => {
|
||||
if (data.learning || !data.baseline) {
|
||||
@@ -97,7 +111,7 @@ export function CoachReadStrip({
|
||||
low: fmt(low),
|
||||
high: fmt(high),
|
||||
value: fmt(latest),
|
||||
unit,
|
||||
unit: resolvedUnit,
|
||||
});
|
||||
})();
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { getUnitForType } from "@/lib/validations/measurement";
|
||||
import { useUnitDisplay } from "@/hooks/use-unit-display";
|
||||
import {
|
||||
MEASUREMENT_TYPE_ICONS,
|
||||
MEASUREMENT_TYPE_LABEL_KEYS,
|
||||
@@ -70,23 +71,6 @@ const UP_BAD_VITALS = new Set([
|
||||
/** Up-is-good for the vitals where a rise is favourable. */
|
||||
const UP_GOOD_VITALS = new Set(["HEART_RATE_VARIABILITY", "OXYGEN_SATURATION"]);
|
||||
|
||||
/**
|
||||
* Storage-unit → display-symbol overrides. `getUnitForType` returns the
|
||||
* canonical *storage* string (e.g. the literal "celsius"); a tile must show
|
||||
* the symbol a user reads ("°C"). Only the units whose storage string is not
|
||||
* already a display symbol need an entry — kg / bpm / ms / % / mg/dL render
|
||||
* fine as-is.
|
||||
*/
|
||||
const DISPLAY_UNIT_SYMBOL: Record<string, string> = {
|
||||
celsius: "°C",
|
||||
fahrenheit: "°F",
|
||||
};
|
||||
|
||||
function displayUnit(type: string): string {
|
||||
const storage = getUnitForType(type);
|
||||
return DISPLAY_UNIT_SYMBOL[storage] ?? storage;
|
||||
}
|
||||
|
||||
function vitalSentiment(type: string): TrendDirectionSentiment {
|
||||
if (UP_BAD_VITALS.has(type)) return "up-bad";
|
||||
if (UP_GOOD_VITALS.has(type)) return "up-good";
|
||||
@@ -229,6 +213,7 @@ function BaselineTile({
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const unitDisplay = useUnitDisplay();
|
||||
// `VITALS_BASELINE` is the type-generic engine and needs the type on the
|
||||
// token; the dedicated bands pin their own single input, so no `type`.
|
||||
const data = read<VitalsBaselineValue>(
|
||||
@@ -247,7 +232,12 @@ function BaselineTile({
|
||||
const Icon = MEASUREMENT_TYPE_ICONS[type] ?? Gauge;
|
||||
const labelKey = MEASUREMENT_TYPE_LABEL_KEYS[type];
|
||||
const label = labelKey ? t(labelKey) : type;
|
||||
const unit = displayUnit(type);
|
||||
// v1.32.26 — resolve the display unit from the user's preference. For an
|
||||
// absolute temperature this converts celsius→°F (affine); the reporter's
|
||||
// proven canonical-label bug (a value labelled by the storage unit rather
|
||||
// than the preferred one) is fixed here, retiring the local celsius shim.
|
||||
const unit = unitDisplay.unitFor(type);
|
||||
const toShown = (raw: number) => unitDisplay.toDisplay(type, raw);
|
||||
// `VITALS_BASELINE` tiles tag the DOM by their vital type (the stable
|
||||
// contract the existing surfaces + tests key on); the dedicated bands tag
|
||||
// by their own metric id.
|
||||
@@ -283,19 +273,23 @@ function BaselineTile({
|
||||
}
|
||||
|
||||
const v = data.value!;
|
||||
// Range bounds + centre + sparkline are ABSOLUTE readings → affine-convert.
|
||||
const framing = t("insights.derived.vitals.typicalRange", {
|
||||
low: fmt.number(v.low, 1),
|
||||
high: fmt.number(v.high, 1),
|
||||
low: fmt.number(toShown(v.low), 1),
|
||||
high: fmt.number(toShown(v.high), 1),
|
||||
});
|
||||
const displaySeries = unitDisplay.isTransformed(type)
|
||||
? v.series.map(toShown)
|
||||
: v.series;
|
||||
|
||||
return (
|
||||
<div data-slot="vitals-tile" data-metric={tileId} data-state="ok">
|
||||
<SparklineDeltaTile
|
||||
label={label}
|
||||
value={v.center}
|
||||
value={toShown(v.center)}
|
||||
unit={unit}
|
||||
icon={Icon}
|
||||
series={v.series}
|
||||
series={displaySeries}
|
||||
framing={framing}
|
||||
directionSentiment={vitalSentiment(type)}
|
||||
provenance={
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useUnitDisplay } from "@/hooks/use-unit-display";
|
||||
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { resolveIntlLocale } from "@/lib/format-locale";
|
||||
@@ -75,6 +76,16 @@ export function DeviceScoreTile({
|
||||
}: DeviceScoreTileProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
// v1.32.26 — a type with a registered metric/imperial transform resolves its
|
||||
// display unit + conversion from the user's preference (e.g. the Oura body-
|
||||
// temperature DEVIATION renders in Δ°F for an imperial user). The affine
|
||||
// `toDisplay` is correct for the readout: the deviation transform carries no
|
||||
// offset (a Δ°C → Δ°F is factor-only), so no absolute shift leaks in.
|
||||
const unitDisplay = useUnitDisplay();
|
||||
const transform = unitDisplay.isTransformed(type)
|
||||
? unitDisplay.transformFor(type)
|
||||
: null;
|
||||
const resolvedUnit = transform ? transform.displayUnit : unit;
|
||||
|
||||
const count = summary?.count ?? 0;
|
||||
if (count === 0) return null;
|
||||
@@ -84,10 +95,13 @@ export function DeviceScoreTile({
|
||||
const isLearning = count < learningThreshold;
|
||||
|
||||
const fmt = (value: number) =>
|
||||
value.toLocaleString(resolveIntlLocale(locale), {
|
||||
minimumFractionDigits: fractionDigits,
|
||||
maximumFractionDigits: fractionDigits,
|
||||
});
|
||||
(transform ? unitDisplay.toDisplay(type, value) : value).toLocaleString(
|
||||
resolveIntlLocale(locale),
|
||||
{
|
||||
minimumFractionDigits: fractionDigits,
|
||||
maximumFractionDigits: fractionDigits,
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<Card
|
||||
@@ -112,9 +126,9 @@ export function DeviceScoreTile({
|
||||
className="text-foreground text-lg font-semibold tabular-nums"
|
||||
>
|
||||
{fmt(latest)}
|
||||
{unit ? (
|
||||
{resolvedUnit ? (
|
||||
<span className="text-muted-foreground ml-1 text-xs font-normal">
|
||||
{unit}
|
||||
{resolvedUnit}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
@@ -136,8 +150,10 @@ export function DeviceScoreTile({
|
||||
types={[type]}
|
||||
title={title}
|
||||
colors={[color]}
|
||||
unit={unit ?? ""}
|
||||
unit={resolvedUnit ?? ""}
|
||||
mini
|
||||
valueScale={transform ? transform.factor : undefined}
|
||||
valueOffset={transform ? (transform.offset ?? 0) : undefined}
|
||||
userTimezone={user?.timezone}
|
||||
/>
|
||||
</div>
|
||||
@@ -147,7 +163,7 @@ export function DeviceScoreTile({
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("insights.deviceScore.average", { value: fmt(mean) })}
|
||||
{unit ? ` ${unit}` : ""}
|
||||
{resolvedUnit ? ` ${resolvedUnit}` : ""}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
|
||||
@@ -27,6 +27,7 @@ import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useUnitDisplay } from "@/hooks/use-unit-display";
|
||||
import type { Glp1PlateauContext } from "@/lib/insights/glp1-plateau";
|
||||
|
||||
interface Glp1PlateauResponse {
|
||||
@@ -36,6 +37,7 @@ interface Glp1PlateauResponse {
|
||||
|
||||
export function Glp1PlateauNote() {
|
||||
const { t } = useTranslations();
|
||||
const unitDisplay = useUnitDisplay();
|
||||
const { data } = useQuery({
|
||||
queryKey: queryKeys.insightsGlp1Plateau(),
|
||||
queryFn: () => apiGet<Glp1PlateauResponse>("/api/insights/glp1-plateau"),
|
||||
@@ -45,10 +47,13 @@ export function Glp1PlateauNote() {
|
||||
if (!data?.plateau) return null;
|
||||
const plateau = data.plateau;
|
||||
|
||||
const delta =
|
||||
plateau.weightDeltaKg > 0
|
||||
? `+${plateau.weightDeltaKg}`
|
||||
: `${plateau.weightDeltaKg}`;
|
||||
// The weight change is a DELTA → factor-only conversion (no affine offset).
|
||||
const deltaValue =
|
||||
Math.round(
|
||||
unitDisplay.toDisplayDelta("WEIGHT", plateau.weightDeltaKg) * 10,
|
||||
) / 10;
|
||||
const delta = deltaValue > 0 ? `+${deltaValue}` : `${deltaValue}`;
|
||||
const unit = unitDisplay.unitFor("WEIGHT");
|
||||
const dose = `${plateau.drug} ${plateau.doseValue} ${plateau.doseUnit}`;
|
||||
|
||||
return (
|
||||
@@ -64,6 +69,7 @@ export function Glp1PlateauNote() {
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("medications.efficacy.plateau.evidence", {
|
||||
delta,
|
||||
unit,
|
||||
window: data.windowDays,
|
||||
readings: plateau.readingsCount,
|
||||
days: plateau.daysOnDose,
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { ComponentProps, ComponentType, ReactNode } from "react";
|
||||
import { RefreshCw, Sparkles } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useUnitDisplay } from "@/hooks/use-unit-display";
|
||||
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
|
||||
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
|
||||
import { useChartDomainStats } from "@/hooks/use-chart-domain-stats";
|
||||
@@ -88,8 +89,14 @@ export interface HealthKitMetricPageProps {
|
||||
* token retune (see UI standards, colour token rules).
|
||||
*/
|
||||
color: `var(--${string})`;
|
||||
/** Unit suffix the chart renders next to the value. */
|
||||
unit: string;
|
||||
/**
|
||||
* Unit suffix the chart renders next to the value. Optional: for a type
|
||||
* with a registered metric/imperial transform (weight-class, temperature,
|
||||
* waist) the scaffold resolves the unit + scale from the user's preference
|
||||
* and IGNORES any page-passed `unit` / `yAxisUnit` / `valueScale`. Pages for
|
||||
* transformed types omit these props; everything else still passes `unit`.
|
||||
*/
|
||||
unit?: string;
|
||||
/** Optional y-axis label shown above the unit. */
|
||||
yAxisUnit?: string;
|
||||
/** Optional value bands (Apple-Health-style target zone shading). */
|
||||
@@ -246,22 +253,60 @@ export function HealthKitMetricPage({
|
||||
? (fallbackMeasurementType as string)
|
||||
: measurementType;
|
||||
|
||||
// v1.32.26 — resolve the display unit + scale from the user's preference for
|
||||
// a type with a registered transform (kg↔lb, cm↔in, °C↔°F). When resolved,
|
||||
// the scaffold OWNS the unit / y-axis unit / value scale / offset / bands +
|
||||
// stat precision and IGNORES the page-passed values, so no page can double-
|
||||
// scale or drift its unit label from the line. Untransformed types keep the
|
||||
// page-passed props exactly (glucose, pulse, %, scores, …).
|
||||
const unitDisplay = useUnitDisplay();
|
||||
const transform = unitDisplay.isTransformed(effectiveType)
|
||||
? unitDisplay.transformFor(effectiveType)
|
||||
: null;
|
||||
const resolvedUnit = transform ? transform.displayUnit : unit;
|
||||
const resolvedYAxisUnit = transform ? transform.displayUnit : yAxisUnit;
|
||||
const resolvedScale = transform ? transform.factor : (valueScale ?? 1);
|
||||
const resolvedOffset = transform ? (transform.offset ?? 0) : 0;
|
||||
const resolvedFractionDigits = transform
|
||||
? transform.decimals
|
||||
: statFractionDigits;
|
||||
// Value bands are absolute bounds → affine-convert (factor + offset) so the
|
||||
// shaded zone tracks the converted line. Metric users get the identity.
|
||||
const resolvedBands =
|
||||
transform && valueBands
|
||||
? valueBands.map((band) => ({
|
||||
...band,
|
||||
min: unitDisplay.toDisplay(effectiveType, band.min),
|
||||
max: unitDisplay.toDisplay(effectiveType, band.max),
|
||||
}))
|
||||
: valueBands;
|
||||
|
||||
const rawSummary = analytics?.summaries?.[effectiveType] ?? null;
|
||||
// The stat strip renders display-unit values. The summary holds stored
|
||||
// values, so when the page renders a scaled unit (e.g. WALKING_SPEED
|
||||
// stores m/s but displays km/h via `valueScale`), fold the same scale
|
||||
// into the strip's min / max / median / mean so the numbers and the
|
||||
// unit agree. `valueScale` defaults to 1 (identity) → byte-identical
|
||||
// for every non-scaled metric.
|
||||
const scale = valueScale ?? 1;
|
||||
// values, so fold the resolved scale + offset (affine) into the strip's
|
||||
// min / max / median / mean so the numbers and the unit agree. All four are
|
||||
// ABSOLUTE values, so they take the offset; an untransformed metric keeps
|
||||
// scale 1 / offset 0 → byte-identical.
|
||||
const summary =
|
||||
rawSummary && scale !== 1
|
||||
rawSummary && (resolvedScale !== 1 || resolvedOffset !== 0)
|
||||
? {
|
||||
...rawSummary,
|
||||
min: rawSummary.min === null ? null : rawSummary.min * scale,
|
||||
max: rawSummary.max === null ? null : rawSummary.max * scale,
|
||||
mean: rawSummary.mean === null ? null : rawSummary.mean * scale,
|
||||
median: rawSummary.median === null ? null : rawSummary.median * scale,
|
||||
min:
|
||||
rawSummary.min === null
|
||||
? null
|
||||
: rawSummary.min * resolvedScale + resolvedOffset,
|
||||
max:
|
||||
rawSummary.max === null
|
||||
? null
|
||||
: rawSummary.max * resolvedScale + resolvedOffset,
|
||||
mean:
|
||||
rawSummary.mean === null
|
||||
? null
|
||||
: rawSummary.mean * resolvedScale + resolvedOffset,
|
||||
median:
|
||||
rawSummary.median === null
|
||||
? null
|
||||
: rawSummary.median * resolvedScale + resolvedOffset,
|
||||
}
|
||||
: rawSummary;
|
||||
|
||||
@@ -353,8 +398,8 @@ export function HealthKitMetricPage({
|
||||
statStrip={
|
||||
<MetricStatStrip
|
||||
summary={summary}
|
||||
unit={yAxisUnit ?? unit}
|
||||
fractionDigits={statFractionDigits}
|
||||
unit={resolvedYAxisUnit ?? resolvedUnit ?? ""}
|
||||
fractionDigits={resolvedFractionDigits}
|
||||
seriesLabel={title}
|
||||
icon={statIcon}
|
||||
windowStats={statsByType?.[effectiveType] ?? null}
|
||||
@@ -364,9 +409,9 @@ export function HealthKitMetricPage({
|
||||
coachReadStrip={
|
||||
<CoachReadStrip
|
||||
metricType={effectiveType}
|
||||
unit={yAxisUnit ?? unit}
|
||||
fractionDigits={statFractionDigits}
|
||||
valueScale={valueScale}
|
||||
unit={resolvedYAxisUnit ?? resolvedUnit ?? ""}
|
||||
fractionDigits={resolvedFractionDigits}
|
||||
valueScale={resolvedScale}
|
||||
/>
|
||||
}
|
||||
diversityNudge={
|
||||
@@ -393,12 +438,13 @@ export function HealthKitMetricPage({
|
||||
}
|
||||
titleIcon={statIcon}
|
||||
colors={[color]}
|
||||
unit={unit}
|
||||
yAxisUnit={yAxisUnit}
|
||||
valueBands={valueBands}
|
||||
unit={resolvedUnit}
|
||||
yAxisUnit={resolvedYAxisUnit}
|
||||
valueBands={resolvedBands}
|
||||
compareBaseline={compareBaseline}
|
||||
userTimezone={user?.timezone}
|
||||
valueScale={valueScale}
|
||||
valueScale={resolvedScale}
|
||||
valueOffset={resolvedOffset}
|
||||
onVisibleStats={onVisibleStats}
|
||||
/>
|
||||
{targetSummarySlug ? (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useUnitDisplay } from "@/hooks/use-unit-display";
|
||||
import { moodTagIcon } from "@/components/mood/mood-tag-icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { MoodInfluenceConfidence } from "./mood-tag-influence";
|
||||
@@ -85,6 +86,7 @@ export function MoodFactorMetricCrosstab({
|
||||
rows: MoodFactorMetricCrosstabRow[];
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const unitDisplay = useUnitDisplay();
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
return (
|
||||
@@ -99,7 +101,16 @@ export function MoodFactorMetricCrosstab({
|
||||
const metricLabel = t(
|
||||
METRIC_LABEL_KEY[row.metricKey] ?? row.metricKey,
|
||||
);
|
||||
const unit = t(UNIT_KEY[row.display]);
|
||||
// v1.32.26 — the weight ("kg") row follows the metric/imperial
|
||||
// preference: the unit symbol + the values (averages + delta, all in
|
||||
// kg, offset-free) convert together. Every other display is unitless
|
||||
// to the preference and keeps its i18n label + raw value.
|
||||
const isWeightRow = row.display === "kg";
|
||||
const unit = isWeightRow
|
||||
? unitDisplay.unitFor("WEIGHT")
|
||||
: t(UNIT_KEY[row.display]);
|
||||
const conv = (v: number) =>
|
||||
isWeightRow ? unitDisplay.toDisplay("WEIGHT", v) : v;
|
||||
// `delta` = lowAvg − highAvg: positive = the vital runs higher on the
|
||||
// low-factor (worse) days. The number stays NEUTRAL — a higher value
|
||||
// is good for steps but bad for resting HR, and this board is
|
||||
@@ -107,7 +118,7 @@ export function MoodFactorMetricCrosstab({
|
||||
// green/red would imply a health verdict the data doesn't support.
|
||||
// The sign prefix + `data-direction` carry the direction.
|
||||
const up = row.delta >= 0;
|
||||
const deltaText = `${up ? "+" : ""}${fmt(row.delta, row.display)} ${unit}`;
|
||||
const deltaText = `${up ? "+" : ""}${fmt(conv(row.delta), row.display)} ${unit}`;
|
||||
return (
|
||||
<li
|
||||
key={`${row.metricKey}:${row.factor}`}
|
||||
@@ -153,8 +164,8 @@ export function MoodFactorMetricCrosstab({
|
||||
{
|
||||
factor: factorLabel,
|
||||
metric: metricLabel,
|
||||
lowAvg: fmt(row.lowAvg, row.display),
|
||||
highAvg: fmt(row.highAvg, row.display),
|
||||
lowAvg: fmt(conv(row.lowAvg), row.display),
|
||||
highAvg: fmt(conv(row.highAvg), row.display),
|
||||
unit,
|
||||
lowDays: row.lowDays,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
/**
|
||||
* v1.32.26 (issue #627) — the manual entry form labels its value field in the
|
||||
* user's preferred unit for a type with a metric/imperial transform. A metric
|
||||
* user sees "kg"; an imperial user sees "lb". The field is the entry boundary
|
||||
* that converts the typed number back to canonical SI on submit, so the label
|
||||
* and the stored unit must agree with the preference.
|
||||
*/
|
||||
|
||||
let preference: "metric" | "imperial" = "metric";
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: {
|
||||
id: "u1",
|
||||
username: "testuser",
|
||||
role: "USER",
|
||||
unitPreference: preference,
|
||||
},
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQueryClient: () => ({
|
||||
invalidateQueries: vi.fn(),
|
||||
refetchQueries: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/api-fetch", () => ({
|
||||
apiPost: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { MeasurementForm } from "../measurement-form";
|
||||
|
||||
function render(): string {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<MeasurementForm defaultType="WEIGHT" />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("MeasurementForm — preferred-unit value label", () => {
|
||||
it("labels the weight field in kg for a metric user", () => {
|
||||
preference = "metric";
|
||||
const html = render();
|
||||
expect(html).toContain("kg");
|
||||
expect(html).not.toContain("lb");
|
||||
// The metric placeholder (kg) is shown, not the imperial one.
|
||||
expect(html).toContain('placeholder="75.5"');
|
||||
});
|
||||
|
||||
it("labels the weight field in lb for an imperial user", () => {
|
||||
preference = "imperial";
|
||||
const html = render();
|
||||
expect(html).toContain("lb");
|
||||
// The imperial placeholder (lb) is shown.
|
||||
expect(html).toContain('placeholder="165"');
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import { Loader2, MoreHorizontal, Plus, RotateCcw } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useUnitDisplay } from "@/hooks/use-unit-display";
|
||||
import {
|
||||
invalidateKeys,
|
||||
measurementDependentKeys,
|
||||
@@ -50,6 +51,7 @@ const MEASUREMENT_TYPES = [
|
||||
labelKey: "measurements.typeWeight",
|
||||
unit: "kg",
|
||||
placeholder: "75.5",
|
||||
placeholderImperial: "165",
|
||||
},
|
||||
{
|
||||
value: "PULSE",
|
||||
@@ -86,12 +88,14 @@ const MEASUREMENT_TYPES = [
|
||||
labelKey: "measurements.typeTotalBodyWater",
|
||||
unit: "kg",
|
||||
placeholder: "42",
|
||||
placeholderImperial: "93",
|
||||
},
|
||||
{
|
||||
value: "BONE_MASS",
|
||||
labelKey: "measurements.typeBoneMass",
|
||||
unit: "kg",
|
||||
placeholder: "3.2",
|
||||
placeholderImperial: "7",
|
||||
},
|
||||
{
|
||||
value: "OXYGEN_SATURATION",
|
||||
@@ -104,6 +108,7 @@ const MEASUREMENT_TYPES = [
|
||||
labelKey: "measurements.typeBodyTemperature",
|
||||
unit: "°C",
|
||||
placeholder: "36.6",
|
||||
placeholderImperial: "97.9",
|
||||
},
|
||||
// v1.25 — physical / clinical signals. Manual web capture so a web-only
|
||||
// self-hoster can log them (the iOS client + measurements API already do).
|
||||
@@ -112,6 +117,7 @@ const MEASUREMENT_TYPES = [
|
||||
labelKey: "measurements.typeWaistCircumference",
|
||||
unit: "cm",
|
||||
placeholder: "84",
|
||||
placeholderImperial: "33",
|
||||
},
|
||||
{
|
||||
value: "WAIST_TO_HEIGHT",
|
||||
@@ -124,6 +130,7 @@ const MEASUREMENT_TYPES = [
|
||||
labelKey: "measurements.typeGripStrength",
|
||||
unit: "kg",
|
||||
placeholder: "38",
|
||||
placeholderImperial: "84",
|
||||
},
|
||||
{
|
||||
value: "PAIN_NRS",
|
||||
@@ -232,6 +239,7 @@ export function MeasurementForm({
|
||||
}: MeasurementFormProps) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const unitDisplay = useUnitDisplay();
|
||||
|
||||
// Normalize legacy BP types to combined mode
|
||||
const normalizedDefault =
|
||||
@@ -327,10 +335,24 @@ export function MeasurementForm({
|
||||
|
||||
await apiPost("/api/measurements", batch);
|
||||
} else {
|
||||
// Single measurement
|
||||
// Single measurement. Canonical storage stays SI: an imperial user
|
||||
// types in lb / in / °F and we invert to the canonical unit at the
|
||||
// entry boundary. A metric user (or an untransformed type) takes the
|
||||
// identity path — `fromDisplay` returns the typed number unchanged, so
|
||||
// the payload is byte-identical to before. Round only the converted
|
||||
// imperial value so 210 lb stores as 95.25 kg, not 95.25439770….
|
||||
const typed = parseFloat(value);
|
||||
let canonicalValue = typed;
|
||||
if (unitDisplay.isTransformed(type)) {
|
||||
const inverted = unitDisplay.fromDisplay(type, typed);
|
||||
canonicalValue =
|
||||
unitDisplay.preference === "imperial"
|
||||
? Math.round(inverted * 100) / 100
|
||||
: inverted;
|
||||
}
|
||||
await apiPost("/api/measurements", {
|
||||
type,
|
||||
value: parseFloat(value),
|
||||
value: canonicalValue,
|
||||
measuredAt: timestamp,
|
||||
notes: notes || undefined,
|
||||
...(isGlucoseMode ? { glucoseContext } : {}),
|
||||
@@ -483,10 +505,15 @@ export function MeasurementForm({
|
||||
<FieldGroup
|
||||
htmlFor="value"
|
||||
label={t("measurements.valueWithUnit", {
|
||||
// For a type with a metric/imperial transform the label follows the
|
||||
// user's preference (kg↔lb, cm↔in, °C↔°F); everything else keeps
|
||||
// its static catalogue unit.
|
||||
unit: typeInfo
|
||||
? "unitKey" in typeInfo
|
||||
? t(typeInfo.unitKey)
|
||||
: typeInfo.unit
|
||||
? unitDisplay.isTransformed(type)
|
||||
? unitDisplay.unitFor(type)
|
||||
: "unitKey" in typeInfo
|
||||
? t(typeInfo.unitKey)
|
||||
: typeInfo.unit
|
||||
: "",
|
||||
})}
|
||||
>
|
||||
@@ -498,8 +525,13 @@ export function MeasurementForm({
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder={
|
||||
typeInfo && "placeholder" in typeInfo
|
||||
? typeInfo.placeholder
|
||||
typeInfo
|
||||
? unitDisplay.preference === "imperial" &&
|
||||
"placeholderImperial" in typeInfo
|
||||
? typeInfo.placeholderImperial
|
||||
: "placeholder" in typeInfo
|
||||
? typeInfo.placeholder
|
||||
: undefined
|
||||
: undefined
|
||||
}
|
||||
required
|
||||
|
||||
@@ -66,6 +66,8 @@ import { formatDateOrRelative, formatDateTime } from "@/lib/format";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { CUMULATIVE_DAY_SUM_TYPES } from "@/lib/measurements/cumulative-day-sum";
|
||||
import { rawDisplayFractionDigits } from "@/lib/measurements/display-transform";
|
||||
import { getUnitForType } from "@/lib/validations/measurement";
|
||||
import { useUnitDisplay } from "@/hooks/use-unit-display";
|
||||
import {
|
||||
invalidateKeys,
|
||||
measurementDependentKeys,
|
||||
@@ -282,6 +284,24 @@ export function MeasurementList({
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const unitDisplay = useUnitDisplay();
|
||||
// v1.32.26 — a single-reading row renders its value + unit in the user's
|
||||
// preferred unit (kg↔lb, cm↔in, °C↔°F). A canonical row converts through the
|
||||
// transform; a legacy non-canonical row (an old MCP write whose `unit` ≠ the
|
||||
// type's canonical unit) is shown verbatim so its stored unit is never
|
||||
// silently relabelled. Metric users take the identity path — unchanged.
|
||||
const rowDisplay = useCallback(
|
||||
(m: { type: string; value: number; unit: string }) => {
|
||||
if (m.unit !== getUnitForType(m.type)) {
|
||||
return { value: m.value, unit: m.unit };
|
||||
}
|
||||
return {
|
||||
value: unitDisplay.toDisplay(m.type, m.value),
|
||||
unit: unitDisplay.unitFor(m.type),
|
||||
};
|
||||
},
|
||||
[unitDisplay],
|
||||
);
|
||||
// v1.28.42 (H3) — the desktop table and the mobile card list used to BOTH
|
||||
// render (only CSS `display` hid one), so a dense cumulative (up to ~5000
|
||||
// rows) or sleep page built every row subtree twice — double the DOM, double
|
||||
@@ -710,8 +730,20 @@ export function MeasurementList({
|
||||
return;
|
||||
}
|
||||
|
||||
// Seed the input in the user's preferred unit so an imperial user edits in
|
||||
// lb / in / °F (converted back to canonical on save). Metric users and
|
||||
// legacy non-canonical rows take the identity path — the seed is the raw
|
||||
// stored value, unchanged. Round the converted imperial seed so the input
|
||||
// shows "210" rather than a long float.
|
||||
const rd = rowDisplay(measurement);
|
||||
const seedValue =
|
||||
unitDisplay.preference === "imperial" &&
|
||||
unitDisplay.isTransformed(measurement.type) &&
|
||||
measurement.unit === getUnitForType(measurement.type)
|
||||
? Math.round(rd.value * 100) / 100
|
||||
: measurement.value;
|
||||
const seed = {
|
||||
value: String(measurement.value),
|
||||
value: String(seedValue),
|
||||
measuredAt: toDateTimeLocalValue(measurement.measuredAt),
|
||||
notes: (measurement.notes ?? "").slice(0, MAX_COMMENT_LENGTH),
|
||||
};
|
||||
@@ -761,10 +793,25 @@ export function MeasurementList({
|
||||
return;
|
||||
}
|
||||
|
||||
// The input holds a display-unit value; invert it to canonical SI before
|
||||
// the PATCH so an imperial edit persists in the stored unit. Metric users
|
||||
// and legacy non-canonical rows are the identity path — value unchanged.
|
||||
let canonicalValue = parsedValue;
|
||||
if (
|
||||
unitDisplay.isTransformed(editing.type) &&
|
||||
editing.unit === getUnitForType(editing.type)
|
||||
) {
|
||||
const inverted = unitDisplay.fromDisplay(editing.type, parsedValue);
|
||||
canonicalValue =
|
||||
unitDisplay.preference === "imperial"
|
||||
? Math.round(inverted * 100) / 100
|
||||
: inverted;
|
||||
}
|
||||
|
||||
setEditError(null);
|
||||
updateMutation.mutate({
|
||||
id: editing.id,
|
||||
value: parsedValue,
|
||||
value: canonicalValue,
|
||||
measuredAt: measuredDate.toISOString(),
|
||||
notes: editNotes.trim() ? editNotes.trim() : null,
|
||||
});
|
||||
@@ -1050,10 +1097,10 @@ export function MeasurementList({
|
||||
{isGrouped
|
||||
? fmt.integer(m.value)
|
||||
: fmt.number(
|
||||
m.value,
|
||||
rowDisplay(m).value,
|
||||
rawDisplayFractionDigits(m.type),
|
||||
)}{" "}
|
||||
{m.unit}
|
||||
{isGrouped ? m.unit : rowDisplay(m).unit}
|
||||
{isGrouped && (
|
||||
<span className="text-muted-foreground ml-2 text-xs font-normal">
|
||||
{t("measurements.dailyTotalCaption", {
|
||||
@@ -1253,10 +1300,10 @@ export function MeasurementList({
|
||||
{isGrouped
|
||||
? fmt.integer(m.value)
|
||||
: fmt.number(
|
||||
m.value,
|
||||
rowDisplay(m).value,
|
||||
rawDisplayFractionDigits(m.type),
|
||||
)}{" "}
|
||||
{m.unit}
|
||||
{isGrouped ? m.unit : rowDisplay(m).unit}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
@@ -1423,7 +1470,9 @@ export function MeasurementList({
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-value">
|
||||
{t("measurements.valueWithUnit", { unit: editing.unit })}
|
||||
{t("measurements.valueWithUnit", {
|
||||
unit: rowDisplay(editing).unit,
|
||||
})}
|
||||
</Label>
|
||||
<Input
|
||||
id="edit-value"
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.32.26 — the single client choke point for metric/imperial display.
|
||||
*
|
||||
* Reads the user's `unitPreference` from the cached `/api/auth/me` payload
|
||||
* (`useAuth`, keyed under `queryKeys.authMe()`, invalidated by the settings
|
||||
* select on change — no extra fetch) and hands back preference-bound sugar
|
||||
* over `getDisplayTransform`. Every render surface that shows a measurement
|
||||
* value or unit for a type in `TRANSFORMS` resolves through this hook, so the
|
||||
* preference is honoured in exactly one place and no surface hardcodes a unit
|
||||
* string or scale.
|
||||
*
|
||||
* The underlying `display-transform.ts` module stays pure and server-safe (it
|
||||
* is imported by an API route); only this thin wrapper is a client hook.
|
||||
*/
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import {
|
||||
applyDisplayTransform,
|
||||
applyDisplayTransformDelta,
|
||||
getDisplayTransform,
|
||||
invertDisplayTransform,
|
||||
hasDisplayTransform,
|
||||
type DisplayTransform,
|
||||
type UnitPreference,
|
||||
} from "@/lib/measurements/display-transform";
|
||||
|
||||
export interface UnitDisplay {
|
||||
/** Resolved preference ("metric" by default / on a stale payload). */
|
||||
preference: UnitPreference;
|
||||
/** The full transform for a type under the current preference. */
|
||||
transformFor: (type: string) => DisplayTransform;
|
||||
/** Convert a raw canonical ABSOLUTE value to the display value. */
|
||||
toDisplay: (type: string, rawValue: number) => number;
|
||||
/** Convert a display ABSOLUTE value back to canonical (entry boundary). */
|
||||
fromDisplay: (type: string, displayValue: number) => number;
|
||||
/** Convert a DELTA / slope / SD — factor only, never the affine offset. */
|
||||
toDisplayDelta: (type: string, deltaValue: number) => number;
|
||||
/** The display unit symbol for a type under the current preference. */
|
||||
unitFor: (type: string) => string;
|
||||
/** Display decimal places for a type under the current preference. */
|
||||
decimalsFor: (type: string) => number;
|
||||
/** True when a type actually rescales under preference (vs identity). */
|
||||
isTransformed: (type: string) => boolean;
|
||||
}
|
||||
|
||||
export function useUnitDisplay(): UnitDisplay {
|
||||
const { user } = useAuth();
|
||||
const preference: UnitPreference =
|
||||
user?.unitPreference === "imperial" ? "imperial" : "metric";
|
||||
|
||||
return useMemo<UnitDisplay>(() => {
|
||||
const transformFor = (type: string) =>
|
||||
getDisplayTransform(type, preference);
|
||||
return {
|
||||
preference,
|
||||
transformFor,
|
||||
toDisplay: (type, rawValue) =>
|
||||
applyDisplayTransform(rawValue, transformFor(type)),
|
||||
fromDisplay: (type, displayValue) =>
|
||||
invertDisplayTransform(displayValue, transformFor(type)),
|
||||
toDisplayDelta: (type, deltaValue) =>
|
||||
applyDisplayTransformDelta(deltaValue, transformFor(type)),
|
||||
unitFor: (type) => transformFor(type).displayUnit,
|
||||
decimalsFor: (type) => transformFor(type).decimals,
|
||||
isTransformed: (type) => hasDisplayTransform(type),
|
||||
};
|
||||
}, [preference]);
|
||||
}
|
||||
@@ -18,11 +18,11 @@
|
||||
*
|
||||
* - `type` — a `measurementTypeEnum` value (e.g. WEIGHT). Unknown → skipped.
|
||||
* - `value` — a number in the row's `unit`.
|
||||
* - `unit` — the canonical unit for the type, OR a recognised alias:
|
||||
* glucose mmol/L → mg/dL (× 18.016),
|
||||
* weight lb → kg (× 0.453592).
|
||||
* Any other non-canonical unit → skipped (never silently
|
||||
* mis-stored).
|
||||
* - `unit` — the canonical unit for the type, OR a recognised alias
|
||||
* (glucose mmol/L → mg/dL, mass lb → kg, waist in → cm,
|
||||
* temperature °F → °C) via the shared
|
||||
* `resolveToCanonicalUnit` resolver. Any other
|
||||
* non-canonical unit → skipped (never silently mis-stored).
|
||||
* - `measuredAt` — ISO-8601 WITH an explicit offset (`Z` or ±HH:MM). A row
|
||||
* without an offset is skipped — the importer never guesses
|
||||
* a timezone. The instant is bounded by `validateEntryInstant`
|
||||
@@ -36,15 +36,11 @@
|
||||
import {
|
||||
measurementTypeEnum,
|
||||
glucoseContextEnum,
|
||||
getUnitForType,
|
||||
validateMeasurementRange,
|
||||
MEASUREMENT_NOTES_MAX_LENGTH,
|
||||
} from "@/lib/validations/measurement";
|
||||
import { isPlausibleEntryInstant } from "@/lib/validations/entry-instant";
|
||||
|
||||
/** Canonical conversion factors keyed by `${type}:${loweredAlias}`. */
|
||||
const GLUCOSE_MMOL_TO_MGDL = 18.016;
|
||||
const LB_TO_KG = 0.453592;
|
||||
import { resolveToCanonicalUnit } from "@/lib/measurements/unit-aliases";
|
||||
|
||||
/** A normalised, write-ready row in the `/api/import` measurement shape. */
|
||||
export interface NormalisedMeasurementRow {
|
||||
@@ -165,39 +161,6 @@ function hasExplicitOffset(value: string): boolean {
|
||||
return /([zZ]|[+-]\d{2}:?\d{2})$/.test(value.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the row's unit to the canonical unit + converted value, or return
|
||||
* `null` when the unit is neither canonical nor a recognised alias.
|
||||
*/
|
||||
function convertToCanonicalUnit(
|
||||
type: string,
|
||||
value: number,
|
||||
rawUnit: string,
|
||||
): { value: number; unit: string } | null {
|
||||
const canonical = getUnitForType(type);
|
||||
const unit = rawUnit.trim();
|
||||
// Case-insensitive match against the canonical unit (mg/dL vs MG/DL).
|
||||
if (unit.toLowerCase() === canonical.toLowerCase()) {
|
||||
return { value, unit: canonical };
|
||||
}
|
||||
const lower = unit.toLowerCase();
|
||||
// Glucose: mmol/L → mg/dL.
|
||||
if (type === "BLOOD_GLUCOSE" && (lower === "mmol/l" || lower === "mmol")) {
|
||||
return { value: value * GLUCOSE_MMOL_TO_MGDL, unit: canonical };
|
||||
}
|
||||
// Weight: lb → kg.
|
||||
if (
|
||||
type === "WEIGHT" &&
|
||||
(lower === "lb" ||
|
||||
lower === "lbs" ||
|
||||
lower === "pound" ||
|
||||
lower === "pounds")
|
||||
) {
|
||||
return { value: value * LB_TO_KG, unit: canonical };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const KNOWN_TYPES = new Set(measurementTypeEnum.options as readonly string[]);
|
||||
const KNOWN_GLUCOSE_CONTEXTS = new Set(
|
||||
glucoseContextEnum.options as readonly string[],
|
||||
@@ -284,7 +247,7 @@ export function parseCsvMeasurements(
|
||||
continue;
|
||||
}
|
||||
|
||||
const converted = convertToCanonicalUnit(type, value, rawUnit);
|
||||
const converted = resolveToCanonicalUnit(type, value, rawUnit);
|
||||
if (converted === null) {
|
||||
skip("unknown_unit");
|
||||
continue;
|
||||
|
||||
@@ -5,12 +5,15 @@ const logMcpMood = vi.fn();
|
||||
const logMcpBloodPressure = vi.fn();
|
||||
const checkMcpMeasurement = vi.fn();
|
||||
const checkMcpBloodPressure = vi.fn();
|
||||
const resolveMcpMeasurementUnit = vi.fn();
|
||||
vi.mock("../writes", () => ({
|
||||
logMcpMeasurement: (...a: unknown[]) => logMcpMeasurement(...a),
|
||||
logMcpMood: (...a: unknown[]) => logMcpMood(...a),
|
||||
logMcpBloodPressure: (...a: unknown[]) => logMcpBloodPressure(...a),
|
||||
checkMcpMeasurement: (...a: unknown[]) => checkMcpMeasurement(...a),
|
||||
checkMcpBloodPressure: (...a: unknown[]) => checkMcpBloodPressure(...a),
|
||||
resolveMcpMeasurementUnit: (...a: unknown[]) =>
|
||||
resolveMcpMeasurementUnit(...a),
|
||||
}));
|
||||
|
||||
const checkMcpWriteRateLimit = vi.fn();
|
||||
@@ -49,6 +52,10 @@ beforeEach(() => {
|
||||
// would-be record. Individual tests override to assert wouldFail.
|
||||
checkMcpMeasurement.mockReturnValue({ ok: true });
|
||||
checkMcpBloodPressure.mockReturnValue({ ok: true });
|
||||
// Default: the unit hint resolves to canonical, echoing the value through.
|
||||
resolveMcpMeasurementUnit.mockImplementation(
|
||||
(_type: string, value: number) => ({ ok: true, value, unit: "kg" }),
|
||||
);
|
||||
});
|
||||
|
||||
describe("write-tool surface", () => {
|
||||
|
||||
@@ -89,6 +89,52 @@ describe("logMcpMeasurement", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("converts a lb unit hint to canonical kg before writing", async () => {
|
||||
const result = await logMcpMeasurement({
|
||||
userId: "u-1",
|
||||
type: "WEIGHT",
|
||||
value: 210,
|
||||
unit: "lb",
|
||||
idempotencyKey: "key-lb",
|
||||
});
|
||||
|
||||
expect(result.status).toBe("written");
|
||||
const data = measurement.create.mock.calls[0][0].data;
|
||||
expect(data.unit).toBe("kg");
|
||||
// 210 lb → 95.25 kg (stored canonical, never the caller's lb verbatim).
|
||||
expect(data.value).toBeCloseTo(210 * 0.45359237, 5);
|
||||
if (result.status === "written") {
|
||||
expect(result.measurement.unit).toBe("kg");
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses an unsupported unit hint without writing", async () => {
|
||||
const result = await logMcpMeasurement({
|
||||
userId: "u-1",
|
||||
type: "WEIGHT",
|
||||
value: 12,
|
||||
unit: "stone",
|
||||
idempotencyKey: "key-stone",
|
||||
});
|
||||
|
||||
expect(result.status).toBe("unsupported_unit");
|
||||
expect(measurement.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stamps canonical kg when the unit hint is omitted", async () => {
|
||||
const result = await logMcpMeasurement({
|
||||
userId: "u-1",
|
||||
type: "WEIGHT",
|
||||
value: 80,
|
||||
idempotencyKey: "key-none",
|
||||
});
|
||||
|
||||
expect(result.status).toBe("written");
|
||||
const data = measurement.create.mock.calls[0][0].data;
|
||||
expect(data.unit).toBe("kg");
|
||||
expect(data.value).toBe(80);
|
||||
});
|
||||
|
||||
it("refuses a non-capturable / clinical-only type without writing", async () => {
|
||||
const result = await logMcpMeasurement({
|
||||
userId: "u-1",
|
||||
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
logMcpBloodPressure,
|
||||
checkMcpMeasurement,
|
||||
checkMcpBloodPressure,
|
||||
resolveMcpMeasurementUnit,
|
||||
} from "./writes";
|
||||
|
||||
/**
|
||||
@@ -184,7 +185,7 @@ const WRITE_TOOL_DEFINITIONS: McpToolDefinition[] = [
|
||||
.max(20)
|
||||
.optional()
|
||||
.describe(
|
||||
"Optional unit hint; the server uses the type's canonical unit when omitted.",
|
||||
"Optional unit hint. Omit to use the type's canonical unit. A recognised alias (lb/lbs/pound(s), in/inch(es), °F, mmol/L) is converted to canonical; any other unit is refused with unsupported_unit — the value is never stored in a non-canonical unit.",
|
||||
),
|
||||
measuredAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
@@ -214,27 +215,43 @@ const WRITE_TOOL_DEFINITIONS: McpToolDefinition[] = [
|
||||
}
|
||||
|
||||
if (!confirm) {
|
||||
// Run the SAME validation the commit core runs so the preview cannot
|
||||
// show a value that would then be refused on commit.
|
||||
const check = checkMcpMeasurement(type, value, measuredAt);
|
||||
// Run the SAME normalisation + validation the commit core runs so the
|
||||
// preview cannot show a value that would then be refused on commit. The
|
||||
// unit hint resolves to canonical (converting the value) or refuses;
|
||||
// the preview echoes the canonical value the commit would store.
|
||||
const unitResolution = resolveMcpMeasurementUnit(type, value, unit);
|
||||
const previewValue = unitResolution.ok ? unitResolution.value : value;
|
||||
const previewUnit = unitResolution.ok
|
||||
? unitResolution.unit
|
||||
: (unit ?? null);
|
||||
const check = unitResolution.ok
|
||||
? checkMcpMeasurement(type, previewValue, measuredAt)
|
||||
: null;
|
||||
annotate({
|
||||
action: { name: "mcp.tool.write" },
|
||||
meta: { tool: "log_measurement", status: "preview" },
|
||||
});
|
||||
const wouldFail = !unitResolution.ok
|
||||
? { wouldFail: true as const, error: "unsupported_unit" as const }
|
||||
: check && !check.ok
|
||||
? {
|
||||
wouldFail: true as const,
|
||||
error: check.error,
|
||||
reason: check.reason,
|
||||
}
|
||||
: {};
|
||||
return {
|
||||
requiresConfirmation: true,
|
||||
written: false,
|
||||
preview: {
|
||||
type,
|
||||
value,
|
||||
unit: unit ?? null,
|
||||
value: previewValue,
|
||||
unit: previewUnit,
|
||||
measuredAt: measuredAt ? measuredAt.toISOString() : "now",
|
||||
source: "MCP",
|
||||
},
|
||||
instruction: CONFIRM_INSTRUCTION,
|
||||
...(check.ok
|
||||
? {}
|
||||
: { wouldFail: true, error: check.error, reason: check.reason }),
|
||||
...wouldFail,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -254,6 +271,9 @@ const WRITE_TOOL_DEFINITIONS: McpToolDefinition[] = [
|
||||
if (result.status === "unsupported_type") {
|
||||
return { written: false, error: "unsupported_type" };
|
||||
}
|
||||
if (result.status === "unsupported_unit") {
|
||||
return { written: false, error: "unsupported_unit" };
|
||||
}
|
||||
if (result.status === "out_of_range") {
|
||||
return { written: false, error: "out_of_range", reason: result.reason };
|
||||
}
|
||||
|
||||
+52
-4
@@ -26,6 +26,7 @@ import {
|
||||
getUnitForType,
|
||||
validateMeasurementRange,
|
||||
} from "@/lib/validations/measurement";
|
||||
import { resolveToCanonicalUnit } from "@/lib/measurements/unit-aliases";
|
||||
import {
|
||||
isPlausibleEntryInstant,
|
||||
ENTRY_INSTANT_CLOCK_SKEW_MS,
|
||||
@@ -71,6 +72,34 @@ export type McpWriteCheck =
|
||||
| { ok: true }
|
||||
| { ok: false; error: "unsupported_type" | "out_of_range"; reason?: string };
|
||||
|
||||
/**
|
||||
* Resolve a caller-supplied `unit` hint to the canonical unit + converted
|
||||
* value, or refuse it. Canonical storage is an invariant: an omitted hint
|
||||
* stamps the type's canonical unit; a recognised alias (lb/lbs/pound(s), in,
|
||||
* °F, mmol/L) converts the value and stamps canonical; anything else is
|
||||
* refused so a non-canonical unit is never persisted verbatim (the pre-fix
|
||||
* leak — a lb value tagged "kg" corrupts every display surface that trusts
|
||||
* the row's `unit` column). Shared by the confirm-gate preview and the commit
|
||||
* core so both reach one verdict for the same input.
|
||||
*/
|
||||
export type McpUnitResolution =
|
||||
{ ok: true; value: number; unit: string } | { ok: false };
|
||||
|
||||
export function resolveMcpMeasurementUnit(
|
||||
type: MeasurementType,
|
||||
value: number,
|
||||
unit: string | undefined,
|
||||
): McpUnitResolution {
|
||||
if (unit === undefined) {
|
||||
return { ok: true, value, unit: getUnitForType(type) };
|
||||
}
|
||||
const resolved = resolveToCanonicalUnit(type, value, unit);
|
||||
if (resolved === null) {
|
||||
return { ok: false };
|
||||
}
|
||||
return { ok: true, value: resolved.value, unit: resolved.unit };
|
||||
}
|
||||
|
||||
/**
|
||||
* Bound a client-supplied `measuredAt` exactly like the manual measurement
|
||||
* route (`validateEntryInstant`): reject a future instant beyond the 5-min
|
||||
@@ -156,6 +185,7 @@ export interface McpMeasurementRecord {
|
||||
|
||||
export type McpMeasurementResult =
|
||||
| { status: "unsupported_type" }
|
||||
| { status: "unsupported_unit" }
|
||||
| { status: "out_of_range"; reason: string }
|
||||
| { status: "written"; measurement: McpMeasurementRecord }
|
||||
| { status: "already_logged"; measurement: McpMeasurementRecord };
|
||||
@@ -176,9 +206,28 @@ export async function logMcpMeasurement(input: {
|
||||
measuredAt?: Date;
|
||||
idempotencyKey: string;
|
||||
}): Promise<McpMeasurementResult> {
|
||||
// Normalise the caller's unit hint to canonical (converting the value) or
|
||||
// refuse it — a non-canonical unit must never be persisted verbatim. Runs
|
||||
// BEFORE the range check so the plausibility gate sees the canonical value
|
||||
// (e.g. 210 lb → 95.25 kg passes WEIGHT {1..500}).
|
||||
const unitResolution = resolveMcpMeasurementUnit(
|
||||
input.type,
|
||||
input.value,
|
||||
input.unit,
|
||||
);
|
||||
if (!unitResolution.ok) {
|
||||
annotate({
|
||||
action: { name: "mcp.tool.write" },
|
||||
meta: { tool: "log_measurement", status: "unsupported_unit" },
|
||||
});
|
||||
return { status: "unsupported_unit" };
|
||||
}
|
||||
const value = unitResolution.value;
|
||||
const unit = unitResolution.unit;
|
||||
|
||||
// Same validation the confirm-gate preview runs (type allowlist, range, and
|
||||
// the measuredAt instant bound) — preview and commit reach one verdict.
|
||||
const check = checkMcpMeasurement(input.type, input.value, input.measuredAt);
|
||||
const check = checkMcpMeasurement(input.type, value, input.measuredAt);
|
||||
if (!check.ok) {
|
||||
annotate({
|
||||
action: { name: "mcp.tool.write" },
|
||||
@@ -193,13 +242,12 @@ export async function logMcpMeasurement(input: {
|
||||
};
|
||||
}
|
||||
|
||||
const unit = input.unit ?? getUnitForType(input.type);
|
||||
const measuredAt = input.measuredAt ?? new Date();
|
||||
const externalId = mcpExternalId("measure", input.idempotencyKey);
|
||||
|
||||
const record: McpMeasurementRecord = {
|
||||
type: input.type,
|
||||
value: input.value,
|
||||
value,
|
||||
unit,
|
||||
measuredAt: measuredAt.toISOString(),
|
||||
source: MCP_SOURCE,
|
||||
@@ -238,7 +286,7 @@ export async function logMcpMeasurement(input: {
|
||||
data: {
|
||||
userId: input.userId,
|
||||
type: input.type,
|
||||
value: input.value,
|
||||
value,
|
||||
unit,
|
||||
source: MCP_SOURCE,
|
||||
measuredAt,
|
||||
|
||||
@@ -2,7 +2,11 @@ import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
applyDisplayTransform,
|
||||
applyDisplayTransformDelta,
|
||||
invertDisplayTransform,
|
||||
getDisplayTransform,
|
||||
hasDisplayTransform,
|
||||
TRANSFORMED_TYPES,
|
||||
DEFAULT_UNIT_PREFERENCE,
|
||||
} from "../display-transform";
|
||||
|
||||
@@ -65,4 +69,116 @@ describe("display-transform", () => {
|
||||
getDisplayTransform("WALKING_SPEED", "metric"),
|
||||
);
|
||||
});
|
||||
|
||||
// ── v1.32.26 — weight-class / length / temperature registry ──
|
||||
|
||||
it("converts WEIGHT kg → lb on the imperial branch", () => {
|
||||
const metric = getDisplayTransform("WEIGHT", "metric");
|
||||
expect(metric.factor).toBe(1);
|
||||
expect(metric.displayUnit).toBe("kg");
|
||||
// Metric is the identity on the number — byte-identical to storage.
|
||||
expect(applyDisplayTransform(95.25, metric)).toBe(95.25);
|
||||
|
||||
const imperial = getDisplayTransform("WEIGHT", "imperial");
|
||||
expect(imperial.displayUnit).toBe("lb");
|
||||
// 95.25 kg → 210.0 lb.
|
||||
expect(applyDisplayTransform(95.25, imperial)).toBeCloseTo(210.0, 1);
|
||||
});
|
||||
|
||||
it("shares the mass branch across every body-mass metric", () => {
|
||||
for (const type of [
|
||||
"TOTAL_BODY_WATER",
|
||||
"BONE_MASS",
|
||||
"FAT_MASS",
|
||||
"FAT_FREE_MASS",
|
||||
"MUSCLE_MASS",
|
||||
"LEAN_BODY_MASS",
|
||||
"GRIP_STRENGTH",
|
||||
]) {
|
||||
expect(getDisplayTransform(type, "imperial").displayUnit).toBe("lb");
|
||||
expect(getDisplayTransform(type, "metric").displayUnit).toBe("kg");
|
||||
// 10 kg → 22.05 lb.
|
||||
expect(
|
||||
applyDisplayTransform(10, getDisplayTransform(type, "imperial")),
|
||||
).toBeCloseTo(22.0462, 3);
|
||||
}
|
||||
});
|
||||
|
||||
it("converts WAIST_CIRCUMFERENCE cm → in", () => {
|
||||
const imperial = getDisplayTransform("WAIST_CIRCUMFERENCE", "imperial");
|
||||
expect(imperial.displayUnit).toBe("in");
|
||||
// 84 cm → 33.07 in.
|
||||
expect(applyDisplayTransform(84, imperial)).toBeCloseTo(33.07, 2);
|
||||
});
|
||||
|
||||
it("converts an ABSOLUTE temperature °C → °F affinely (factor + offset)", () => {
|
||||
const metric = getDisplayTransform("BODY_TEMPERATURE", "metric");
|
||||
expect(metric.displayUnit).toBe("°C");
|
||||
expect(metric.offset ?? 0).toBe(0);
|
||||
|
||||
const imperial = getDisplayTransform("BODY_TEMPERATURE", "imperial");
|
||||
expect(imperial.displayUnit).toBe("°F");
|
||||
expect(imperial.factor).toBe(1.8);
|
||||
expect(imperial.offset).toBe(32);
|
||||
// 36.6 °C → 97.88 °F (the +32 shift MUST apply to an absolute reading).
|
||||
expect(applyDisplayTransform(36.6, imperial)).toBeCloseTo(97.88, 2);
|
||||
// 0 °C → 32 °F (not 0).
|
||||
expect(applyDisplayTransform(0, imperial)).toBe(32);
|
||||
});
|
||||
|
||||
it("skin + wrist temperature share the affine temperature branch", () => {
|
||||
for (const type of ["SKIN_TEMPERATURE", "WRIST_TEMPERATURE"]) {
|
||||
const imperial = getDisplayTransform(type, "imperial");
|
||||
expect(imperial.displayUnit).toBe("°F");
|
||||
expect(imperial.offset).toBe(32);
|
||||
}
|
||||
});
|
||||
|
||||
it("BODY_TEMPERATURE_DEVIATION scales °C→°F WITHOUT the offset (it is a Δ)", () => {
|
||||
const imperial = getDisplayTransform(
|
||||
"BODY_TEMPERATURE_DEVIATION",
|
||||
"imperial",
|
||||
);
|
||||
expect(imperial.displayUnit).toBe("°F");
|
||||
expect(imperial.factor).toBe(1.8);
|
||||
// A signed deviation is a delta: 1 Δ°C is a 1.8 Δ°F, NEVER 33.8 °F.
|
||||
expect(imperial.offset ?? 0).toBe(0);
|
||||
expect(applyDisplayTransform(1, imperial)).toBeCloseTo(1.8, 5);
|
||||
});
|
||||
|
||||
it("the delta helper NEVER applies the affine offset", () => {
|
||||
const imperial = getDisplayTransform("BODY_TEMPERATURE", "imperial");
|
||||
// Same absolute transform (offset 32), but a delta must be factor-only.
|
||||
expect(applyDisplayTransform(1, imperial)).toBe(33.8);
|
||||
expect(applyDisplayTransformDelta(1, imperial)).toBeCloseTo(1.8, 5);
|
||||
// A 2 °C rise reads as a 3.6 °F rise, not a 35.6 °F reading.
|
||||
expect(applyDisplayTransformDelta(2, imperial)).toBeCloseTo(3.6, 5);
|
||||
});
|
||||
|
||||
it("inverse round-trips every registered branch (entry ⇄ display)", () => {
|
||||
for (const type of TRANSFORMED_TYPES) {
|
||||
for (const pref of ["metric", "imperial"] as const) {
|
||||
const transform = getDisplayTransform(type, pref);
|
||||
for (const raw of [0, 1, 36.6, 84, 95.25, 210]) {
|
||||
const shown = applyDisplayTransform(raw, transform);
|
||||
expect(invertDisplayTransform(shown, transform)).toBeCloseTo(raw, 6);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("hasDisplayTransform + TRANSFORMED_TYPES agree, and exclude BMI/glucose", () => {
|
||||
expect(hasDisplayTransform("WEIGHT")).toBe(true);
|
||||
expect(hasDisplayTransform("BODY_TEMPERATURE")).toBe(true);
|
||||
expect(hasDisplayTransform("PULSE")).toBe(false);
|
||||
// Deliberately excluded (own preference / universal unit).
|
||||
expect(TRANSFORMED_TYPES.has("BLOOD_GLUCOSE")).toBe(false);
|
||||
expect(TRANSFORMED_TYPES.has("BODY_MASS_INDEX")).toBe(false);
|
||||
// Every registered type resolves a non-identity or symbol-only metric
|
||||
// branch, and both branches exist.
|
||||
for (const type of TRANSFORMED_TYPES) {
|
||||
expect(getDisplayTransform(type, "metric").displayUnit).toBeTruthy();
|
||||
expect(getDisplayTransform(type, "imperial").displayUnit).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { resolveToCanonicalUnit } from "../unit-aliases";
|
||||
|
||||
describe("resolveToCanonicalUnit", () => {
|
||||
it("passes the canonical unit through case-insensitively", () => {
|
||||
expect(resolveToCanonicalUnit("WEIGHT", 80, "kg")).toEqual({
|
||||
value: 80,
|
||||
unit: "kg",
|
||||
});
|
||||
expect(resolveToCanonicalUnit("WEIGHT", 80, "KG")).toEqual({
|
||||
value: 80,
|
||||
unit: "kg",
|
||||
});
|
||||
expect(resolveToCanonicalUnit("BLOOD_GLUCOSE", 95, "MG/DL")).toEqual({
|
||||
value: 95,
|
||||
unit: "mg/dL",
|
||||
});
|
||||
});
|
||||
|
||||
it("converts a lb weight to canonical kg (exact factor)", () => {
|
||||
const out = resolveToCanonicalUnit("WEIGHT", 210, "lb");
|
||||
expect(out?.unit).toBe("kg");
|
||||
expect(out?.value).toBeCloseTo(210 * 0.45359237, 6);
|
||||
// aliases
|
||||
for (const alias of ["lbs", "pound", "pounds", "LB"]) {
|
||||
expect(resolveToCanonicalUnit("WEIGHT", 1, alias)?.unit).toBe("kg");
|
||||
}
|
||||
});
|
||||
|
||||
it("converts lb for every body-mass metric, not just WEIGHT", () => {
|
||||
for (const type of ["MUSCLE_MASS", "FAT_MASS", "BONE_MASS"]) {
|
||||
const out = resolveToCanonicalUnit(type, 22.0462, "lb");
|
||||
expect(out?.unit).toBe("kg");
|
||||
expect(out?.value).toBeCloseTo(10, 3);
|
||||
}
|
||||
});
|
||||
|
||||
it("converts waist inches to canonical cm", () => {
|
||||
const out = resolveToCanonicalUnit("WAIST_CIRCUMFERENCE", 33, "in");
|
||||
expect(out?.unit).toBe("cm");
|
||||
expect(out?.value).toBeCloseTo(83.82, 2);
|
||||
});
|
||||
|
||||
it("converts an absolute °F reading to canonical celsius", () => {
|
||||
const out = resolveToCanonicalUnit("BODY_TEMPERATURE", 98.6, "°F");
|
||||
expect(out?.unit).toBe("celsius");
|
||||
expect(out?.value).toBeCloseTo(37, 5);
|
||||
// an explicit celsius spelling also normalises to the canonical string
|
||||
expect(resolveToCanonicalUnit("BODY_TEMPERATURE", 37, "°C")).toEqual({
|
||||
value: 37,
|
||||
unit: "celsius",
|
||||
});
|
||||
});
|
||||
|
||||
it("converts glucose mmol/L to canonical mg/dL", () => {
|
||||
const out = resolveToCanonicalUnit("BLOOD_GLUCOSE", 5.3, "mmol/L");
|
||||
expect(out?.unit).toBe("mg/dL");
|
||||
expect(out?.value).toBeCloseTo(5.3 * 18.016, 3);
|
||||
});
|
||||
|
||||
it("refuses an unrecognised or type-mismatched unit (never mis-stores)", () => {
|
||||
expect(resolveToCanonicalUnit("WEIGHT", 12, "stone")).toBeNull();
|
||||
expect(resolveToCanonicalUnit("WEIGHT", 12, "°F")).toBeNull();
|
||||
// lb is not a waist unit
|
||||
expect(resolveToCanonicalUnit("WAIST_CIRCUMFERENCE", 12, "lb")).toBeNull();
|
||||
// mmol/L is not a weight unit
|
||||
expect(resolveToCanonicalUnit("WEIGHT", 12, "mmol/L")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -3,8 +3,12 @@
|
||||
*
|
||||
* Canonical storage stays SI (the iOS wire contract + the Withings
|
||||
* passthrough both lock raw SI — see the convention block in
|
||||
* `apple-health-mapping.ts`). A handful of metrics read better in a
|
||||
* derived unit at the display boundary only:
|
||||
* `apple-health-mapping.ts`). Storage never changes; the metric/imperial
|
||||
* user preference (`unitPreference`) only selects which branch a
|
||||
* transform exposes at the render boundary.
|
||||
*
|
||||
* A handful of metrics also read better in a derived unit regardless of
|
||||
* preference:
|
||||
*
|
||||
* - WALKING_SPEED is stored in m/s but a casual gait of 1.3 m/s
|
||||
* reads as "4.7 km/h" to a human.
|
||||
@@ -12,16 +16,20 @@
|
||||
* reads as "5000 m"; daily totals make more sense in km.
|
||||
*
|
||||
* The transform is applied at the render layer only (chart `valueScale`
|
||||
* + the measurement-list cell + tooltip). Raw rows, plausibility
|
||||
* ranges, rollup math, and the AI snapshot all keep canonical SI. A
|
||||
* `factor` of 1 is the identity transform and is what every other
|
||||
* type resolves to, so callers that pass an un-transformed value see
|
||||
* no change (charts stay visually identical).
|
||||
* + the measurement-list cell + tooltip + entry form). Raw rows,
|
||||
* plausibility ranges, rollup math, FHIR/CSV export, the doctor report,
|
||||
* and the AI snapshot all keep canonical SI. For a metric user every
|
||||
* transform below is the identity on the number (factor 1, offset 0), so
|
||||
* the metric render is byte-identical to before — charts stay visually
|
||||
* identical. The imperial branch rescales; that is the intended
|
||||
* behaviour, not a regression.
|
||||
*
|
||||
* The metric/imperial user preference (`unitPreference`) selects which
|
||||
* branch a transform exposes. The default is metric; the imperial
|
||||
* branch is additive and only wired for the two distance/speed metrics
|
||||
* so far — every other type ignores the preference entirely.
|
||||
* v1.32.26 — the registry now covers the full weight-class / length /
|
||||
* temperature set the display surfaces render, and the interface gained
|
||||
* an affine `offset` so °C→°F (a shift, not just a scale) can be
|
||||
* expressed. The offset applies to ABSOLUTE values only; deltas, slopes,
|
||||
* standard deviations, and band widths take the factor alone — use
|
||||
* `applyDisplayTransformDelta` for those.
|
||||
*/
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
|
||||
@@ -34,6 +42,13 @@ export const DEFAULT_UNIT_PREFERENCE: UnitPreference = "metric";
|
||||
export interface DisplayTransform {
|
||||
/** Multiplier applied to the raw canonical value for display. */
|
||||
factor: number;
|
||||
/**
|
||||
* Additive shift applied AFTER the factor for an absolute display
|
||||
* value (`display = raw * factor + offset`). Defaults to 0. Present
|
||||
* only for affine conversions (°C→°F). Deltas/slopes/SD must NOT use
|
||||
* it — see `applyDisplayTransformDelta`.
|
||||
*/
|
||||
offset?: number;
|
||||
/** Unit string shown next to the displayed value. */
|
||||
displayUnit: string;
|
||||
/** Decimal places for the displayed value. */
|
||||
@@ -47,20 +62,60 @@ const IDENTITY: DisplayTransform = {
|
||||
decimals: 1,
|
||||
};
|
||||
|
||||
// kg → lb (exact: 1 lb = 0.45359237 kg → 1 kg = 2.20462262185 lb).
|
||||
const KG_TO_LB = 2.20462262185;
|
||||
// cm → in (exact: 1 in = 2.54 cm → 1 cm = 0.393700787402 in).
|
||||
const CM_TO_IN = 0.393700787402;
|
||||
|
||||
/** Shared metric/imperial branch pair for a body-mass metric (kg ↔ lb). */
|
||||
const MASS_TRANSFORM: Record<UnitPreference, DisplayTransform> = {
|
||||
metric: { factor: 1, displayUnit: "kg", decimals: 1 },
|
||||
imperial: { factor: KG_TO_LB, displayUnit: "lb", decimals: 1 },
|
||||
};
|
||||
|
||||
/** Shared branch pair for an ABSOLUTE temperature (°C ↔ °F, affine). */
|
||||
const TEMPERATURE_TRANSFORM: Record<UnitPreference, DisplayTransform> = {
|
||||
metric: { factor: 1, displayUnit: "°C", decimals: 1 },
|
||||
imperial: { factor: 1.8, offset: 32, displayUnit: "°F", decimals: 1 },
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-type, per-preference transforms. Only types that genuinely read
|
||||
* better in a derived unit appear here; everything else resolves to
|
||||
* the identity transform with the canonical unit from
|
||||
* `getUnitForType()`.
|
||||
* Per-type, per-preference transforms. Types absent here resolve to the
|
||||
* identity transform with the canonical unit from `getUnitForType()`.
|
||||
*
|
||||
* Metric branch ships in v1.7.0. The imperial branch is additive: it
|
||||
* covers the same two metrics in mph / miles so the global
|
||||
* metric/imperial toggle has somewhere to land without a follow-up
|
||||
* migration.
|
||||
* The metric branch of every entry below is the identity on the number
|
||||
* (factor 1, no offset), differing from `getUnitForType` only in the
|
||||
* DISPLAY symbol it surfaces (e.g. "°C" for the stored "celsius"). The
|
||||
* imperial branch carries the real conversion.
|
||||
*/
|
||||
const TRANSFORMS: Partial<
|
||||
Record<MeasurementType, Record<UnitPreference, DisplayTransform>>
|
||||
> = {
|
||||
// ── Body-mass metrics (kg canonical → lb imperial) ──
|
||||
WEIGHT: MASS_TRANSFORM,
|
||||
TOTAL_BODY_WATER: MASS_TRANSFORM,
|
||||
BONE_MASS: MASS_TRANSFORM,
|
||||
FAT_MASS: MASS_TRANSFORM,
|
||||
FAT_FREE_MASS: MASS_TRANSFORM,
|
||||
MUSCLE_MASS: MASS_TRANSFORM,
|
||||
LEAN_BODY_MASS: MASS_TRANSFORM,
|
||||
GRIP_STRENGTH: MASS_TRANSFORM,
|
||||
// ── Circumference (cm canonical → in imperial) ──
|
||||
WAIST_CIRCUMFERENCE: {
|
||||
metric: { factor: 1, displayUnit: "cm", decimals: 1 },
|
||||
imperial: { factor: CM_TO_IN, displayUnit: "in", decimals: 1 },
|
||||
},
|
||||
// ── Absolute temperatures (°C canonical → °F imperial, affine) ──
|
||||
BODY_TEMPERATURE: TEMPERATURE_TRANSFORM,
|
||||
SKIN_TEMPERATURE: TEMPERATURE_TRANSFORM,
|
||||
WRIST_TEMPERATURE: TEMPERATURE_TRANSFORM,
|
||||
// A signed baseline DEVIATION (Δ°C) — the °F imperial branch scales by
|
||||
// 1.8 but carries NO +32 offset. A one-degree-Celsius deviation is a
|
||||
// 1.8-degree-Fahrenheit deviation, not a 33.8 °F reading.
|
||||
BODY_TEMPERATURE_DEVIATION: {
|
||||
metric: { factor: 1, displayUnit: "°C", decimals: 1 },
|
||||
imperial: { factor: 1.8, displayUnit: "°F", decimals: 1 },
|
||||
},
|
||||
// 1 m/s = 3.6 km/h = 2.236936 mph.
|
||||
WALKING_SPEED: {
|
||||
metric: { factor: 3.6, displayUnit: "km/h", decimals: 1 },
|
||||
@@ -73,6 +128,20 @@ const TRANSFORMS: Partial<
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Every type with a registered transform. The structural guard test uses
|
||||
* this set to assert no display surface hardcodes a unit string / scale
|
||||
* for a type that should route through the transform instead.
|
||||
*/
|
||||
export const TRANSFORMED_TYPES: ReadonlySet<string> = new Set(
|
||||
Object.keys(TRANSFORMS),
|
||||
);
|
||||
|
||||
/** True when `type` has a per-preference transform (vs the identity path). */
|
||||
export function hasDisplayTransform(type: string): boolean {
|
||||
return TRANSFORMED_TYPES.has(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the display transform for a `(type, preference)` pair.
|
||||
* Returns the identity transform (with the canonical unit) for every
|
||||
@@ -117,13 +186,38 @@ export function rawDisplayFractionDigits(type: string): number | undefined {
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a transform's factor to a raw canonical value. Pure helper —
|
||||
* keeps the multiply in one place so the chart, list cell, and tooltip
|
||||
* all scale identically.
|
||||
* Apply a transform to a raw canonical ABSOLUTE value:
|
||||
* `display = raw * factor + offset`. Pure helper — keeps the arithmetic
|
||||
* in one place so the chart, list cell, tooltip, and entry form all
|
||||
* scale identically.
|
||||
*/
|
||||
export function applyDisplayTransform(
|
||||
rawValue: number,
|
||||
transform: DisplayTransform,
|
||||
): number {
|
||||
return rawValue * transform.factor;
|
||||
return rawValue * transform.factor + (transform.offset ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a transform to a DELTA / slope / standard-deviation / band-width
|
||||
* — factor only, NEVER the offset. A 1 °C change is a 1.8 °F change; it
|
||||
* must not pick up the +32 absolute-scale shift.
|
||||
*/
|
||||
export function applyDisplayTransformDelta(
|
||||
deltaValue: number,
|
||||
transform: DisplayTransform,
|
||||
): number {
|
||||
return deltaValue * transform.factor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invert an absolute display value back to the canonical stored value:
|
||||
* `raw = (display - offset) / factor`. Used at the entry boundary so an
|
||||
* imperial user's typed number persists in canonical SI.
|
||||
*/
|
||||
export function invertDisplayTransform(
|
||||
displayValue: number,
|
||||
transform: DisplayTransform,
|
||||
): number {
|
||||
return (displayValue - (transform.offset ?? 0)) / transform.factor;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* v1.32.26 — shared inbound unit-alias resolver.
|
||||
*
|
||||
* Canonical storage is SI (`getUnitForType`). A few write paths accept a
|
||||
* caller-supplied unit string and must normalise it to the canonical unit
|
||||
* (converting the value) or refuse it outright — they must NEVER persist a
|
||||
* non-canonical unit verbatim, or the row's `unit` column stops being an
|
||||
* invariant and every display surface that trusts it silently mislabels.
|
||||
*
|
||||
* This is the single resolver behind both inbound paths that take a unit
|
||||
* string: the CSV importer (`csv-measurements.ts`) and the MCP
|
||||
* `log_measurement` tool (`mcp/writes.ts`). It recognises the canonical unit
|
||||
* (case-insensitively) and a closed set of common aliases (lb/lbs/pound(s),
|
||||
* in/inch(es), °F, mmol/L); anything else returns `null` so the caller can
|
||||
* skip / refuse rather than mis-store.
|
||||
*
|
||||
* Pure + side-effect-free; safe to import from an API route or a script.
|
||||
*/
|
||||
import { getUnitForType } from "@/lib/validations/measurement";
|
||||
|
||||
/** Glucose mmol/L → mg/dL. */
|
||||
const GLUCOSE_MMOL_TO_MGDL = 18.016;
|
||||
/** Exact pound → kilogram (1 lb = 0.45359237 kg). */
|
||||
const LB_TO_KG = 0.45359237;
|
||||
/** Exact inch → centimetre (1 in = 2.54 cm). */
|
||||
const IN_TO_CM = 2.54;
|
||||
|
||||
/** Body-mass metrics stored in kg — a lb value converts, all others skip. */
|
||||
const MASS_TYPES: ReadonlySet<string> = new Set([
|
||||
"WEIGHT",
|
||||
"TOTAL_BODY_WATER",
|
||||
"BONE_MASS",
|
||||
"FAT_MASS",
|
||||
"FAT_FREE_MASS",
|
||||
"MUSCLE_MASS",
|
||||
"LEAN_BODY_MASS",
|
||||
"GRIP_STRENGTH",
|
||||
]);
|
||||
|
||||
/** Absolute-temperature metrics stored in °C (canonical string "celsius"). */
|
||||
const TEMPERATURE_TYPES: ReadonlySet<string> = new Set([
|
||||
"BODY_TEMPERATURE",
|
||||
"SKIN_TEMPERATURE",
|
||||
"WRIST_TEMPERATURE",
|
||||
]);
|
||||
|
||||
const LB_ALIASES: ReadonlySet<string> = new Set([
|
||||
"lb",
|
||||
"lbs",
|
||||
"pound",
|
||||
"pounds",
|
||||
]);
|
||||
const IN_ALIASES: ReadonlySet<string> = new Set(["in", "inch", "inches", '"']);
|
||||
const FAHRENHEIT_ALIASES: ReadonlySet<string> = new Set([
|
||||
"f",
|
||||
"°f",
|
||||
"fahrenheit",
|
||||
]);
|
||||
const CELSIUS_ALIASES: ReadonlySet<string> = new Set(["c", "°c", "celsius"]);
|
||||
const MMOL_ALIASES: ReadonlySet<string> = new Set(["mmol/l", "mmol"]);
|
||||
|
||||
export interface CanonicalUnitResult {
|
||||
/** The value expressed in the canonical unit. */
|
||||
value: number;
|
||||
/** The canonical unit string (`getUnitForType(type)`). */
|
||||
unit: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve `(type, value, rawUnit)` to `{ value, unit }` in the canonical
|
||||
* unit, or `null` when `rawUnit` is neither the canonical unit nor a
|
||||
* recognised alias. Never mis-stores: an unknown unit is a hard `null`.
|
||||
*/
|
||||
export function resolveToCanonicalUnit(
|
||||
type: string,
|
||||
value: number,
|
||||
rawUnit: string,
|
||||
): CanonicalUnitResult | null {
|
||||
const canonical = getUnitForType(type);
|
||||
const trimmed = rawUnit.trim();
|
||||
const lower = trimmed.toLowerCase();
|
||||
|
||||
// Canonical match (case-insensitive: "mg/dL" vs "MG/DL", "kg" vs "KG").
|
||||
if (lower === canonical.toLowerCase()) {
|
||||
return { value, unit: canonical };
|
||||
}
|
||||
|
||||
// Glucose: mmol/L → mg/dL.
|
||||
if (type === "BLOOD_GLUCOSE" && MMOL_ALIASES.has(lower)) {
|
||||
return { value: value * GLUCOSE_MMOL_TO_MGDL, unit: canonical };
|
||||
}
|
||||
|
||||
// Body mass: lb → kg.
|
||||
if (MASS_TYPES.has(type) && LB_ALIASES.has(lower)) {
|
||||
return { value: value * LB_TO_KG, unit: canonical };
|
||||
}
|
||||
|
||||
// Waist circumference: in → cm.
|
||||
if (type === "WAIST_CIRCUMFERENCE" && IN_ALIASES.has(lower)) {
|
||||
return { value: value * IN_TO_CM, unit: canonical };
|
||||
}
|
||||
|
||||
// Absolute temperature: °F → °C (affine), or an explicit °C spelling.
|
||||
if (TEMPERATURE_TYPES.has(type)) {
|
||||
if (FAHRENHEIT_ALIASES.has(lower)) {
|
||||
return { value: (value - 32) / 1.8, unit: canonical };
|
||||
}
|
||||
if (CELSIUS_ALIASES.has(lower)) {
|
||||
return { value, unit: canonical };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
Reference in new issue
Block a user