diff --git a/src/lib/jobs/__tests__/reaction-line-units.test.ts b/src/lib/jobs/__tests__/reaction-line-units.test.ts new file mode 100644 index 000000000..d738bbb76 --- /dev/null +++ b/src/lib/jobs/__tests__/reaction-line-units.test.ts @@ -0,0 +1,80 @@ +/** + * The datum a reaction line reacts to is handed to the model in the reader's + * units, so the line cannot quote kilograms to a reader on pounds. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const measurementFindMany = vi.fn(); +const workoutFindFirst = vi.fn(); + +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { + findMany: (...a: unknown[]) => measurementFindMany(...a), + }, + workout: { findFirst: (...a: unknown[]) => workoutFindFirst(...a) }, + }, +})); + +import { loadArrivalEvidence } from "../reaction-line"; + +const row = { occurredAt: new Date("2026-09-30T07:00:00Z"), refId: "w1" }; +const imperial = { + timezone: "UTC", + sourcePriorityJson: null, + unitPreference: "imperial", + glucoseUnit: null, +}; + +beforeEach(() => { + measurementFindMany.mockReset(); + workoutFindFirst.mockReset(); +}); + +describe("loadArrivalEvidence — the reader's units", () => { + it("states a new weight in pounds for an imperial reader", async () => { + measurementFindMany.mockResolvedValue([{ type: "WEIGHT", value: 80 }]); + const evidence = await loadArrivalEvidence( + { userId: "u1", kind: "weight", localDate: "2026-09-30", revision: "r" }, + row, + imperial, + ); + expect(evidence).toBe("- Newly arrived reading: WEIGHT 176.4 lb."); + }); + + it("states blood pressure in mmHg for every reader", async () => { + measurementFindMany.mockResolvedValue([ + { type: "BLOOD_PRESSURE_DIA", value: 80 }, + { type: "BLOOD_PRESSURE_SYS", value: 125 }, + ]); + const evidence = await loadArrivalEvidence( + { + userId: "u1", + kind: "blood_pressure", + localDate: "2026-09-30", + revision: "r", + }, + row, + imperial, + ); + expect(evidence).toContain("BLOOD_PRESSURE_SYS 125 mmHg."); + }); + + it("states a workout distance in miles, not metres", async () => { + measurementFindMany.mockResolvedValue([]); + workoutFindFirst.mockResolvedValue({ + sportType: "running", + durationSec: 1800, + totalDistanceM: 5000, + totalEnergyKcal: 300, + avgHeartRate: 150, + }); + const evidence = await loadArrivalEvidence( + { userId: "u1", kind: "workout", localDate: "2026-09-30", revision: "r" }, + row, + imperial, + ); + expect(evidence).toContain("distance 3.11 mi;"); + expect(evidence).not.toContain("metres"); + }); +}); diff --git a/src/lib/jobs/reaction-line.ts b/src/lib/jobs/reaction-line.ts index 5d3084acf..3c32cf7d6 100644 --- a/src/lib/jobs/reaction-line.ts +++ b/src/lib/jobs/reaction-line.ts @@ -25,6 +25,12 @@ * The deterministic digest adds only the same computed context rendered next * to the reaction. */ +import { + applyDisplayTransform, + getQuantityTransform, + getReadingTransform, + resolveUnitPreferences, +} from "@/lib/measurements/display-transform"; import type { Job } from "pg-boss"; import { prisma } from "@/lib/db"; @@ -140,11 +146,19 @@ export function sanitiseReactionLine( * intentionally a narrow, labels-and-numbers-only projection: no notes or * other free text can enter the provider prompt through this path. */ -async function loadArrivalEvidence( +export async function loadArrivalEvidence( job: ReactionLineJob, row: { occurredAt: Date; refId: string | null }, - user: { timezone: string; sourcePriorityJson: unknown }, + user: { + timezone: string; + sourcePriorityJson: unknown; + unitPreference: string | null; + glucoseUnit: string | null; + }, ): Promise { + // The line is written in the reader's units, so the new datum it reacts to + // is handed over in them. + const units = resolveUnitPreferences(user); if (job.kind === "weight" || job.kind === "blood_pressure") { const types = job.kind === "weight" @@ -157,15 +171,15 @@ async function loadArrivalEvidence( measuredAt: row.occurredAt, deletedAt: null, }, - select: { type: true, value: true, unit: true }, + select: { type: true, value: true }, orderBy: { type: "asc" }, }); if (readings.length > 0) { return readings - .map( - (reading) => - `- Newly arrived reading: ${reading.type} ${reading.value} ${reading.unit}.`, - ) + .map((reading) => { + const transform = getReadingTransform(reading.type, units); + return `- Newly arrived reading: ${reading.type} ${applyDisplayTransform(reading.value, transform)} ${transform.displayUnit}.`; + }) .join("\n"); } } @@ -241,7 +255,12 @@ async function loadArrivalEvidence( }, }); if (workout) { - return `- Newly arrived workout: ${workout.sportType}; duration ${workout.durationSec} seconds; distance ${workout.totalDistanceM ?? "not reported"} metres; energy ${workout.totalEnergyKcal ?? "not reported"} kcal; average heart rate ${workout.avgHeartRate ?? "not reported"} bpm.`; + const distance = getQuantityTransform("distance", units.system); + const distanceText = + workout.totalDistanceM == null + ? "not reported" + : `${applyDisplayTransform(workout.totalDistanceM, distance)} ${distance.displayUnit}`; + return `- Newly arrived workout: ${workout.sportType}; duration ${workout.durationSec} seconds; distance ${distanceText}; energy ${workout.totalEnergyKcal ?? "not reported"} kcal; average heart rate ${workout.avgHeartRate ?? "not reported"} bpm.`; } }