diff --git a/scripts/convert-noncanonical-measurement-units.ts b/scripts/convert-noncanonical-measurement-units.ts new file mode 100644 index 000000000..18a75cbda --- /dev/null +++ b/scripts/convert-noncanonical-measurement-units.ts @@ -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 { + 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(); + 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()); diff --git a/scripts/diagnose-noncanonical-measurement-units.ts b/scripts/diagnose-noncanonical-measurement-units.ts new file mode 100644 index 000000000..9c0667bda --- /dev/null +++ b/scripts/diagnose-noncanonical-measurement-units.ts @@ -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 { + 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()); diff --git a/src/__tests__/unit-preference-display-guard.test.ts b/src/__tests__/unit-preference-display-guard.test.ts new file mode 100644 index 000000000..ff91da3c0 --- /dev/null +++ b/src/__tests__/unit-preference-display-guard.test.ts @@ -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 = 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); + } + }); +}); diff --git a/src/components/measurements/__tests__/measurement-form-unit-label.test.tsx b/src/components/measurements/__tests__/measurement-form-unit-label.test.tsx new file mode 100644 index 000000000..b53d0d50a --- /dev/null +++ b/src/components/measurements/__tests__/measurement-form-unit-label.test.tsx @@ -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( + + + , + ); +} + +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"'); + }); +}); diff --git a/src/lib/mcp/__tests__/writes.test.ts b/src/lib/mcp/__tests__/writes.test.ts index 32776ebf2..22c1eea9f 100644 --- a/src/lib/mcp/__tests__/writes.test.ts +++ b/src/lib/mcp/__tests__/writes.test.ts @@ -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", diff --git a/src/lib/measurements/__tests__/display-transform.test.ts b/src/lib/measurements/__tests__/display-transform.test.ts index 48165c42a..e6203f227 100644 --- a/src/lib/measurements/__tests__/display-transform.test.ts +++ b/src/lib/measurements/__tests__/display-transform.test.ts @@ -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(); + } + }); }); diff --git a/src/lib/measurements/__tests__/unit-aliases.test.ts b/src/lib/measurements/__tests__/unit-aliases.test.ts new file mode 100644 index 000000000..a12f4d860 --- /dev/null +++ b/src/lib/measurements/__tests__/unit-aliases.test.ts @@ -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(); + }); +});