fix(insights): write metric status notes in the reader's units

The per-metric status note quoted glucose in mg/dL, body composition in
kg, temperatures in °C and walking distance in metres, whatever the
reader had chosen; the deterministic floor without a provider did the
same. The generator now converts the series, the reference band and the
signal into the reader's units before they reach the prompt or the
floor, and prints the guideline band table in that unit while still
classifying on the canonical value.

A changed glucose unit or metric/imperial preference (own account or a
managed record) now re-warms the stored notes and narratives through
the hash-gated refill, so today's text does not keep the old unit.

Refs #1067
This commit is contained in:
Marc-André Bombeck committed 2026-10-01 08:52:44 +02:00
1 parent 70e6e07a89
commit 6c6d363bba
13 files changed
+551 -28

No files matched your search

@@ -23,6 +23,10 @@ vi.mock("@/lib/db", () => ({
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/insights/unit-change-refresh", () => ({
refreshTextsAfterUnitChange: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
@@ -56,6 +60,7 @@ import { GET, PATCH } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
import { refreshTextsAfterUnitChange } from "@/lib/insights/unit-change-refresh";
import { checkRateLimit } from "@/lib/rate-limit";
const SESSION_OK = {
@@ -210,3 +215,33 @@ describe("PATCH /api/auth/me/glucose-unit", () => {
expect(prisma.user.update).not.toHaveBeenCalled();
});
});
describe("PATCH re-warms the texts that quote the unit", () => {
it("re-warms the stored notes when the unit actually changes", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
glucoseUnit: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ glucoseUnit: "mmol/L" }),
);
expect(res.status).toBe(200);
expect(refreshTextsAfterUnitChange).toHaveBeenCalledWith("user-1");
});
it("leaves them alone when the same unit is saved again", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
glucoseUnit: "mmol/L",
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ glucoseUnit: "mmol/L" }),
);
expect(res.status).toBe(200);
expect(refreshTextsAfterUnitChange).not.toHaveBeenCalled();
});
});
@@ -38,6 +38,7 @@ import { prisma } from "@/lib/db";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { resolveGlucoseUnit, type GlucoseUnit } from "@/lib/glucose";
import { glucoseUnitPatchSchema } from "@/lib/validations/user-prefs";
import { refreshTextsAfterUnitChange } from "@/lib/insights/unit-change-refresh";
export const dynamic = "force-dynamic";
@@ -112,6 +113,12 @@ export const PATCH = apiHandler(async (req: Request) => {
},
});
// The status notes and narratives quote glucose in this unit; re-warm
// them so today's stored text does not keep the old one.
if (resolveGlucoseUnit(previous?.glucoseUnit) !== next) {
void refreshTextsAfterUnitChange(user.id);
}
annotate({
action: { name: "auth.me.glucose-unit.patch" },
meta: { glucoseUnit: next },
@@ -11,6 +11,10 @@ vi.mock("@/lib/db", () => ({
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/insights/unit-change-refresh", () => ({
refreshTextsAfterUnitChange: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
@@ -43,6 +47,7 @@ import { GET, PATCH } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
import { refreshTextsAfterUnitChange } from "@/lib/insights/unit-change-refresh";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -197,3 +202,33 @@ describe("PATCH /api/auth/me/unit-preference", () => {
expect(prisma.user.update).not.toHaveBeenCalled();
});
});
describe("PATCH re-warms the texts that quote the unit", () => {
it("re-warms the stored notes when the unit actually changes", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
unitPreference: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ unitPreference: "imperial" }),
);
expect(res.status).toBe(200);
expect(refreshTextsAfterUnitChange).toHaveBeenCalledWith("user-1");
});
it("leaves them alone when the same unit is saved again", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
unitPreference: "imperial",
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ unitPreference: "imperial" }),
);
expect(res.status).toBe(200);
expect(refreshTextsAfterUnitChange).not.toHaveBeenCalled();
});
});
@@ -38,6 +38,7 @@ import { prisma } from "@/lib/db";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { DEFAULT_UNIT_PREFERENCE } from "@/lib/measurements/display-transform";
import { unitPreferencePatchSchema } from "@/lib/validations/user-prefs";
import { refreshTextsAfterUnitChange } from "@/lib/insights/unit-change-refresh";
export const dynamic = "force-dynamic";
@@ -118,6 +119,12 @@ export const PATCH = apiHandler(async (req: Request) => {
},
});
// The status notes and narratives quote mass, length and temperature in
// this branch; re-warm them so today's stored text follows the switch.
if (resolve(previous?.unitPreference) !== next) {
void refreshTextsAfterUnitChange(user.id);
}
annotate({
action: { name: "auth.me.unit-preference.patch" },
meta: { unitPreference: next },
@@ -41,6 +41,7 @@ import {
parseNotificationPrefs,
resolveNotificationPrefs,
} from "@/lib/validations/notification-prefs";
import { refreshTextsAfterUnitChange } from "@/lib/insights/unit-change-refresh";
export const dynamic = "force-dynamic";
@@ -230,6 +231,9 @@ export const PATCH = apiHandler(
},
});
invalidateUserProfile(access.recordId);
if (patch.unitPreference !== undefined) {
void refreshTextsAfterUnitChange(access.recordId);
}
if (patch.timezone !== undefined) {
invalidateUserMedications(access.recordId, { evict: true });
}