diff --git a/src/app/api/auth/me/glucose-unit/__tests__/route.test.ts b/src/app/api/auth/me/glucose-unit/__tests__/route.test.ts index a699059cb..1e19944a9 100644 --- a/src/app/api/auth/me/glucose-unit/__tests__/route.test.ts +++ b/src/app/api/auth/me/glucose-unit/__tests__/route.test.ts @@ -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)( + 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)( + mkPatch({ glucoseUnit: "mmol/L" }), + ); + expect(res.status).toBe(200); + expect(refreshTextsAfterUnitChange).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/auth/me/glucose-unit/route.ts b/src/app/api/auth/me/glucose-unit/route.ts index 144fa8df6..b11373224 100644 --- a/src/app/api/auth/me/glucose-unit/route.ts +++ b/src/app/api/auth/me/glucose-unit/route.ts @@ -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 }, diff --git a/src/app/api/auth/me/unit-preference/__tests__/route.test.ts b/src/app/api/auth/me/unit-preference/__tests__/route.test.ts index 17f9e74a3..430a05879 100644 --- a/src/app/api/auth/me/unit-preference/__tests__/route.test.ts +++ b/src/app/api/auth/me/unit-preference/__tests__/route.test.ts @@ -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)( + 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)( + mkPatch({ unitPreference: "imperial" }), + ); + expect(res.status).toBe(200); + expect(refreshTextsAfterUnitChange).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/auth/me/unit-preference/route.ts b/src/app/api/auth/me/unit-preference/route.ts index ae7b459d7..fe17915fa 100644 --- a/src/app/api/auth/me/unit-preference/route.ts +++ b/src/app/api/auth/me/unit-preference/route.ts @@ -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 }, diff --git a/src/app/api/record-settings/[family]/route.ts b/src/app/api/record-settings/[family]/route.ts index 5b91fa3de..f72f62af9 100644 --- a/src/app/api/record-settings/[family]/route.ts +++ b/src/app/api/record-settings/[family]/route.ts @@ -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 }); } diff --git a/src/lib/ai/prompts/__tests__/interpretation-block.test.ts b/src/lib/ai/prompts/__tests__/interpretation-block.test.ts index 31a04abca..010100fb2 100644 --- a/src/lib/ai/prompts/__tests__/interpretation-block.test.ts +++ b/src/lib/ai/prompts/__tests__/interpretation-block.test.ts @@ -9,6 +9,29 @@ import { describe, expect, it } from "vitest"; import { buildInterpretationBlock } from "../interpretation-block"; describe("buildInterpretationBlock", () => { + it("classifies on the canonical value but prints in the reader's unit", () => { + const toF = (c: number) => Math.round((c * 1.8 + 32) * 10) / 10; + const block = buildInterpretationBlock({ + metricKey: "BODY_TEMPERATURE", + value: 36.8, + sex: null, + locale: "en", + display: { unit: "°F", convert: toF }, + }); + expect(block).toBeDefined(); + expect(block).toContain("Current value 98.2 °F"); + expect(block).not.toContain("°C"); + // Same band as the canonical block names. + const canonical = buildInterpretationBlock({ + metricKey: "BODY_TEMPERATURE", + value: 36.8, + sex: null, + locale: "en", + })!; + const band = (text: string) => text.match(/is in the "([^"]+)" band/)![1]; + expect(band(block!)).toBe(band(canonical)); + }); + it("renders the band position for visceral fat 2.7 (the headline case)", () => { const en = buildInterpretationBlock({ metricKey: "VISCERAL_FAT", diff --git a/src/lib/ai/prompts/interpretation-block.ts b/src/lib/ai/prompts/interpretation-block.ts index 84bad478e..7e42185a6 100644 --- a/src/lib/ai/prompts/interpretation-block.ts +++ b/src/lib/ai/prompts/interpretation-block.ts @@ -130,9 +130,17 @@ function proximityPhrase( */ export function buildInterpretationBlock(args: { metricKey: string; + /** The value in the guideline's own (canonical) unit — what is classified. */ value: number; sex: "MALE" | "FEMALE" | null | undefined; locale: Locale; + /** + * Print the block in the reader's unit. Classification always runs on the + * canonical value against the canonical bands; only the printed numbers and + * the unit symbol are converted, so a prompt whose snapshot reads °F does + * not carry a band table in °C beside it. + */ + display?: { unit: string; convert: (canonical: number) => number }; }): string | undefined { const resolved = resolveInterpretation(args.metricKey, args.sex ?? null); if (!resolved) return undefined; @@ -140,22 +148,24 @@ export function buildInterpretationBlock(args: { const loc: "en" | "de" = instructionLocale(args.locale); const pos = classifyBandPosition(args.value, resolved.bands); - const rows = resolved.bands + const show = (n: number) => (args.display ? args.display.convert(n) : n); + const unit = args.display ? args.display.unit : resolved.unit; + const shownBands = resolved.bands.map((band) => ({ + ...band, + upTo: band.upTo === null ? null : show(band.upTo), + })); + + const rows = shownBands .map((band, i) => - bandRow( - band, - i > 0 ? resolved.bands[i - 1].upTo : null, - resolved.unit, - loc, - ), + bandRow(band, i > 0 ? shownBands[i - 1].upTo : null, unit, loc), ) .map((r) => ` - ${r}`) .join("\n"); const proximity = proximityPhrase( pos.proximity, - pos.nearestEdge, - resolved.unit, + pos.nearestEdge === null ? null : show(pos.nearestEdge), + unit, loc, ); const valence = valencePhrase(pos.band.valence, loc); @@ -164,14 +174,14 @@ export function buildInterpretationBlock(args: { if (loc === "en") { return `INTERPRETATION CONTEXT (guideline bands — computed server-side; state, do NOT recompute): -- Current value ${args.value} ${resolved.unit} is in the "${pos.band.label}" band, and ${proximity}. +- Current value ${show(args.value)} ${unit} is in the "${pos.band.label}" band, and ${proximity}. - Guideline bands (${resolved.source}; ${direction}): ${rows} - Values in the "${pos.band.label}" band are considered ${valence} per these guidelines.${caveat} - Name where the value sits and what that band means in plain words. Judge any trend BY this position: a shift deep inside a favourable band is a footnote; the same shift approaching or crossing a boundary is the headline. Frame consequence without diagnosis ("values in this range are considered …", never "you have / are at risk of …").`; } return `EINORDNUNGS-KONTEXT (Leitlinien-Bänder — serverseitig berechnet; nennen, NICHT neu rechnen): -- Der aktuelle Wert ${args.value} ${resolved.unit} liegt im Band "${pos.band.label}" und ${proximity}. +- Der aktuelle Wert ${show(args.value)} ${unit} liegt im Band "${pos.band.label}" und ${proximity}. - Leitlinien-Bänder (${resolved.source}; ${direction}): ${rows} - Werte im Band "${pos.band.label}" gelten laut diesen Leitlinien als ${valence}.${caveat} diff --git a/src/lib/insights/__tests__/metric-status.test.ts b/src/lib/insights/__tests__/metric-status.test.ts index 0f7fe5be4..74903b025 100644 --- a/src/lib/insights/__tests__/metric-status.test.ts +++ b/src/lib/insights/__tests__/metric-status.test.ts @@ -507,3 +507,134 @@ describe("generateMetricStatus — generation path", () => { expect(result.text).toContain("Steady."); }); }); + +describe("generateMetricStatus — the note is written in the reader's units", () => { + function seed(values: (day: number) => number, days = 120) { + const now = new Date(); + const records: Array<{ value: number; measuredAt: Date }> = []; + for (let day = 0; day < days; day++) { + records.push({ + value: values(day), + measuredAt: new Date(now.getTime() - day * dayMs), + }); + } + vi.mocked(prisma.measurement.count).mockResolvedValue(days as never); + vi.mocked(prisma.measurement.findMany).mockResolvedValue(records as never); + } + + function snapshotOf(userPrompt: string) { + return JSON.parse(userPrompt.match(/\{[\s\S]*\}/)![0]); + } + + it("states glucose, its band and its series in mmol/L for an mmol/L reader", async () => { + seed((day) => 99 + (day % 4) * 3); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dateOfBirth: null, + gender: null, + unitPreference: "metric", + glucoseUnit: "mmol/L", + } as never); + const captured = { systemPrompt: null, userPrompt: null } as { + systemPrompt: string | null; + userPrompt: string | null; + }; + stubCompletion('{"summary":"Steady."}', captured); + + await generateMetricStatus({ + metric: "BLOOD_GLUCOSE", + userId: "user-1", + locale: "en", + }); + + expect(captured.systemPrompt).toContain("unit: mmol/L"); + // 70–140 mg/dL is 3.9–7.8 mmol/L. + expect(captured.systemPrompt).toContain("3.9–7.8 mmol/L"); + // The shared safety contract quotes its own fixed thresholds; the metric + // block itself must not carry the canonical band. + expect(captured.systemPrompt).not.toContain("70–140 mg/dL"); + const snapshot = snapshotOf(captured.userPrompt!); + expect(snapshot.metric.unit).toBe("mmol/L"); + expect(snapshot.metric.normalRange).toMatchObject({ low: 3.9, high: 7.8 }); + const recentMeans = snapshot.BLOOD_GLUCOSE.series.recent.map( + (b: { mean: number }) => b.mean, + ); + expect(recentMeans.length).toBeGreaterThan(0); + for (const mean of recentMeans) { + expect(mean).toBeGreaterThan(5); + expect(mean).toBeLessThan(6.5); + } + expect(captured.userPrompt).not.toContain("mg/dL"); + }); + + it("states body temperature in °F for an imperial reader, guideline band included", async () => { + seed((day) => 36.6 + (day % 3) * 0.1); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dateOfBirth: null, + gender: null, + unitPreference: "imperial", + glucoseUnit: null, + } as never); + const captured = { systemPrompt: null, userPrompt: null } as { + systemPrompt: string | null; + userPrompt: string | null; + }; + stubCompletion('{"summary":"Steady."}', captured); + + await generateMetricStatus({ + metric: "BODY_TEMPERATURE", + userId: "user-1", + locale: "en", + }); + + expect(captured.systemPrompt).toContain("unit: °F"); + expect(captured.systemPrompt).not.toContain("36.1–37.2 °C"); + // The interpretation block classifies in °C but prints in °F. + expect(captured.userPrompt).toContain("INTERPRETATION CONTEXT"); + expect(captured.userPrompt).not.toContain("°C"); + expect(snapshotOf(captured.userPrompt!).metric.unit).toBe("°F"); + }); + + it("writes the no-provider floor in the reader's glucose unit", async () => { + seed((day) => 99 + (day % 4) * 3); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dateOfBirth: null, + gender: null, + unitPreference: "metric", + glucoseUnit: "mmol/L", + } as never); + vi.mocked(runStatusCompletion).mockResolvedValue({ kind: "none" } as never); + + const result = await generateMetricStatus({ + metric: "BLOOD_GLUCOSE", + userId: "user-1", + locale: "en", + }); + + expect(result.text).toMatch(/\d\.\d mmol\/L/); + expect(result.text).not.toContain("mg/dL"); + }); + + it("leaves a metric reader on the default units byte-identical", async () => { + seed((day) => 99 + (day % 4) * 3); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dateOfBirth: null, + gender: null, + unitPreference: null, + glucoseUnit: null, + } as never); + const captured = { systemPrompt: null, userPrompt: null } as { + systemPrompt: string | null; + userPrompt: string | null; + }; + stubCompletion('{"summary":"Steady."}', captured); + + await generateMetricStatus({ + metric: "BLOOD_GLUCOSE", + userId: "user-1", + locale: "en", + }); + + expect(captured.systemPrompt).toContain("70–140 mg/dL"); + expect(snapshotOf(captured.userPrompt!).metric.unit).toBe("mg/dL"); + }); +}); diff --git a/src/lib/insights/__tests__/unit-change-refresh.test.ts b/src/lib/insights/__tests__/unit-change-refresh.test.ts new file mode 100644 index 000000000..15a0eac27 --- /dev/null +++ b/src/lib/insights/__tests__/unit-change-refresh.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: vi.fn() }, + insightNarrative: { findMany: vi.fn() }, + }, +})); +vi.mock("@/lib/insights/status-invalidation", () => ({ + enqueueStatusRefillForUser: vi.fn(), +})); +vi.mock("@/lib/jobs/period-narrative-shared", () => ({ + enqueueNarrativeWarm: vi.fn(), +})); + +import { prisma } from "@/lib/db"; +import { enqueueStatusRefillForUser } from "@/lib/insights/status-invalidation"; +import { enqueueNarrativeWarm } from "@/lib/jobs/period-narrative-shared"; +import { refreshTextsAfterUnitChange } from "../unit-change-refresh"; + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe("refreshTextsAfterUnitChange", () => { + it("refills the status cards in the reader's locale and re-warms each stored narrative", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + locale: "de", + } as never); + vi.mocked(enqueueStatusRefillForUser).mockResolvedValue(12); + vi.mocked(prisma.insightNarrative.findMany).mockResolvedValue([ + { period: "week", locale: "de" }, + { period: "month", locale: "en" }, + { period: "year", locale: "de" }, + { period: "week", locale: "xx" }, + ] as never); + + await refreshTextsAfterUnitChange("user-1"); + + expect(enqueueStatusRefillForUser).toHaveBeenCalledWith("user-1", "de"); + expect(enqueueNarrativeWarm).toHaveBeenCalledTimes(2); + expect(enqueueNarrativeWarm).toHaveBeenCalledWith({ + userId: "user-1", + period: "week", + locale: "de", + }); + expect(enqueueNarrativeWarm).toHaveBeenCalledWith({ + userId: "user-1", + period: "month", + locale: "en", + }); + }); + + it("never throws into the preference write", async () => { + vi.mocked(prisma.user.findUnique).mockRejectedValue(new Error("db down")); + await expect(refreshTextsAfterUnitChange("user-1")).resolves.toBe( + undefined, + ); + }); +}); diff --git a/src/lib/insights/graded-series.ts b/src/lib/insights/graded-series.ts index b67298080..73e1938f8 100644 --- a/src/lib/insights/graded-series.ts +++ b/src/lib/insights/graded-series.ts @@ -598,6 +598,35 @@ export function scaleGradedSeries( }; } +/** + * Re-express a canonical graded series in a display unit: absolute bucket + * values (`min / max / mean`) take the full affine conversion, the yearly + * slope — a rate, not a level — takes the factor alone, exactly as + * `applyDisplayTransformDelta` treats a delta. Bucket keys and counts are + * untouched. Used where a series is narrated in the reader's unit; the + * series it is built from stays canonical. + */ +export function convertGradedSeries( + series: GradedSeries, + conversion: { factor: number; offset?: number }, + digits = 2, +): GradedSeries { + const offset = conversion.offset ?? 0; + const abs = (v: number) => round(v * conversion.factor + offset, digits); + const bucket = ( + b: T, + ): T => ({ ...b, min: abs(b.min), max: abs(b.max), mean: abs(b.mean) }); + return { + recent: series.recent.map(bucket), + weekly: series.weekly.map(bucket), + monthly: series.monthly.map(bucket), + yearly: series.yearly.map((b) => ({ + ...bucket(b), + slope: b.slope === null ? null : round(b.slope * conversion.factor, 4), + })), + }; +} + /** * Assembled-snapshot soft char cap, mirroring the Coach's * `MAX_SNAPSHOT_CHARS` (~24 000 chars ≈ ~6 000 tokens against the diff --git a/src/lib/insights/metric-status.ts b/src/lib/insights/metric-status.ts index a7d922cf3..b937b689c 100644 --- a/src/lib/insights/metric-status.ts +++ b/src/lib/insights/metric-status.ts @@ -58,7 +58,9 @@ import { applyPayloadBudget } from "@/lib/insights/bucket-series"; import { buildGradedSeriesFromPoints, buildGradedSeriesWithRollups, + convertGradedSeries, degradeStatusSnapshotToBudget, + type GradedSeries, } from "@/lib/insights/graded-series"; import { type SupportedLocale, @@ -84,6 +86,16 @@ import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash"; import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback"; import { annotate } from "@/lib/logging/context"; import { binaryReferenceSex } from "@/lib/profile/sex"; +import { + applyDisplayTransform, + getReadingTransform, + invertDisplayTransform, + isUnitSensitiveType, + resolveUnitPreferences, + transformRescales, + type DisplayTransform, + type UnitPreferences, +} from "@/lib/measurements/display-transform"; export interface MetricStatusResult { hasProvider: boolean; @@ -222,9 +234,40 @@ export async function generateMetricStatus(args: { const user = await prisma.user.findUnique({ where: { id: args.userId }, - select: { dateOfBirth: true, gender: true }, + select: { + dateOfBirth: true, + gender: true, + unitPreference: true, + glucoseUnit: true, + }, }); + // The note is written in the reader's units. The series, the reference band + // and the signal are converted once, here, before any of them reaches the + // prompt or the deterministic floor; the registry entry, the rollups and + // the guideline classification stay canonical. An untransformed metric (and + // every metric on the default preferences that does not rescale) passes + // through unchanged, so its snapshot hash does not move. + const reading = readingDisplay( + meta, + resolveUnitPreferences({ + unitPreference: user?.unitPreference, + glucoseUnit: user?.glucoseUnit, + }), + ); + const shownMeta: MetricStatusMeta = { + ...meta, + unit: reading.unit, + ...(meta.normalRange + ? { + normalRange: { + low: reading.toDisplay(meta.normalRange.low), + high: reading.toDisplay(meta.normalRange.high), + }, + } + : {}), + }; + const now = new Date(); // Bounded raw read for the derived recent stats; the monthly/yearly @@ -295,7 +338,10 @@ export async function generateMetricStatus(args: { }) .then((r) => r.reverse()); for (const m of rows) { - points.push({ measuredAt: m.measuredAt, value: m.value }); + points.push({ + measuredAt: m.measuredAt, + value: reading.toDisplay(m.value), + }); } measurements = rows.map((m) => ({ measuredAt: m.measuredAt })); } @@ -309,11 +355,13 @@ export async function generateMetricStatus(args: { // recent/weekly/monthly/yearly bucket is a night total, not a stage sum. const graded = isSleep ? buildGradedSeriesFromPoints(points, now, userTz) - : await buildGradedSeriesWithRollups( - args.userId, - meta.measurementType, - now, - userTz, + : reading.convertGraded( + await buildGradedSeriesWithRollups( + args.userId, + meta.measurementType, + now, + userTz, + ), ); const summary = summarizeSeries( series.daily.map((bucket) => ({ value: bucket.value })), @@ -344,7 +392,14 @@ export async function generateMetricStatus(args: { // metric + profile; otherwise keep the existing flat anchor (strictly // additive, never a regression). Age + sex only — no ancestry/region. const sharpenedRange = lookupNormalRange(meta.id, ageYears, sex); - const normalRange = sharpenedRange ?? meta.normalRange; + const canonicalRange = sharpenedRange ?? meta.normalRange; + const normalRange = canonicalRange + ? { + ...canonicalRange, + low: reading.toDisplay(canonicalRange.low), + high: reading.toDisplay(canonicalRange.high), + } + : canonicalRange; // v1.13.x — the SIGNAL block: the recent-vs-baseline comparison + the // normal-swing verdict, computed server-side so the model states it rather @@ -353,7 +408,7 @@ export async function generateMetricStatus(args: { // when the recent window is empty, in which case it is omitted. const signal = buildMetricSignal({ metric: meta.displayName, - unit: meta.unit, + unit: shownMeta.unit, direction: meta.direction, graded, normalRange: normalRange ?? null, @@ -369,7 +424,7 @@ export async function generateMetricStatus(args: { metric: { id: meta.id, displayName: meta.displayName, - unit: meta.unit, + unit: shownMeta.unit, direction: meta.direction, ...(normalRange ? { normalRange } : {}), ...(sharpenedRange ? { normalRangeSource: "age-sex-adjusted" } : {}), @@ -477,14 +532,24 @@ export async function generateMetricStatus(args: { // window exists); absent for uncovered metrics (fail-soft to personal- // relative). Sex-split bands (waist) resolve only when the profile carries a // sex. + // The guideline bands are canonical, so the band position is classified on + // the canonical value; the block then prints in the reader's unit. const interpretationValue = signal?.current ?? latest?.value ?? null; const interpretationBlock = interpretationValue !== null ? buildInterpretationBlock({ metricKey: meta.id, - value: interpretationValue, + value: reading.toCanonical(interpretationValue), sex, locale, + ...(reading.converts + ? { + display: { + unit: reading.unit, + convert: reading.toDisplay, + }, + } + : {}), }) : undefined; @@ -502,9 +567,9 @@ export async function generateMetricStatus(args: { userId: args.userId, cacheAction, capability: "statusText", - systemPrompt: getMetricArchetypeSystemPrompt(meta, locale), + systemPrompt: getMetricArchetypeSystemPrompt(shownMeta, locale), userPrompt: getMetricArchetypeUserPrompt( - meta, + shownMeta, snapshotJson, todayKey, locale, @@ -526,7 +591,7 @@ export async function generateMetricStatus(args: { if (outcome.kind === "none") { return { hasProvider: false, - text: getNoKeyMetricStatusText(locale, signal), + text: getNoKeyMetricStatusText(locale, signal, reading.decimals), cached: true, updatedAt: null, }; @@ -537,7 +602,7 @@ export async function generateMetricStatus(args: { reason: outcome.kind, userId: args.userId, todayKey, - stubText: getNoKeyMetricStatusText(locale, signal), + stubText: getNoKeyMetricStatusText(locale, signal, reading.decimals), }); } @@ -553,7 +618,7 @@ export async function generateMetricStatus(args: { reason: "screened", userId: args.userId, todayKey, - stubText: getNoKeyMetricStatusText(locale, signal), + stubText: getNoKeyMetricStatusText(locale, signal, reading.decimals), }); } const text = screened.text; @@ -577,3 +642,51 @@ export function resolveMetricStatusLocale( ): SupportedLocale { return normalizeLocale(locale); } + +/** + * How one metric's numbers are written for this reader: the unit symbol, the + * conversions in both directions, and the decimals a value is read at. + * + * Only a unit-sensitive type (`isUnitSensitiveType`: glucose and the + * metric/imperial set) converts; every other metric keeps its registry unit + * and its stored numbers. A transform that does not rescale (`converts` + * false) is the identity, so the default preferences leave a snapshot + * byte-identical for the metrics whose registry unit already is the display + * unit. + */ +function readingDisplay( + meta: MetricStatusMeta, + preferences: UnitPreferences, +): { + unit: string; + decimals: number | undefined; + converts: boolean; + toDisplay: (canonical: number) => number; + toCanonical: (display: number) => number; + convertGraded: (series: GradedSeries) => GradedSeries; +} { + if (!isUnitSensitiveType(meta.measurementType)) { + return { + unit: meta.unit, + decimals: undefined, + converts: false, + toDisplay: (v) => v, + toCanonical: (v) => v, + convertGraded: (series) => series, + }; + } + const transform: DisplayTransform = getReadingTransform( + meta.measurementType, + preferences, + ); + const converts = transform.factor !== 1 || (transform.offset ?? 0) !== 0; + return { + unit: transform.displayUnit, + decimals: transform.decimals, + converts, + toDisplay: (v) => applyDisplayTransform(v, transform), + toCanonical: (v) => invertDisplayTransform(v, transform), + convertGraded: (series) => + converts ? convertGradedSeries(series, transform) : series, + }; +} diff --git a/src/lib/insights/no-key-fallbacks.ts b/src/lib/insights/no-key-fallbacks.ts index 7be0863dd..fd54f0cf3 100644 --- a/src/lib/insights/no-key-fallbacks.ts +++ b/src/lib/insights/no-key-fallbacks.ts @@ -439,6 +439,12 @@ const GENERIC_METRIC_POINTER: Localized = { export function getNoKeyMetricStatusText( locale: InsightLocale, signal?: MetricSignal | null, + /** + * Decimals the signal's unit is read at (0 for mg/dL, 1 for mmol/L or + * pounds). Defaults to one, which was the only precision before the + * signal could arrive in the reader's unit. + */ + digits = 1, ): string { const grounded = signal && Number.isFinite(signal.current) @@ -449,7 +455,7 @@ export function getNoKeyMetricStatusText( // localised upstream, so it stands for every locale. label: { en: signal.metric }, ...(signal.unit ? { unit: ` ${signal.unit}` } : {}), - digits: 1, + digits, pointer: GENERIC_METRIC_POINTER, }, locale, diff --git a/src/lib/insights/unit-change-refresh.ts b/src/lib/insights/unit-change-refresh.ts new file mode 100644 index 000000000..de820ceed --- /dev/null +++ b/src/lib/insights/unit-change-refresh.ts @@ -0,0 +1,63 @@ +/** + * Re-warm the stored texts that quote numbers after a unit preference changes. + * + * A status note and a period narrative are written once and served all day + * (the narrative for up to twenty hours). Both now state their figures in the + * reader's units, so a note written before a switch from mg/dL to mmol/L — or + * from metric to imperial — keeps saying the old unit until something + * regenerates it. Nothing did: neither preference write touched the stored + * text. + * + * The refill goes through the same hash-gated path as the manual regenerate: + * every card the record has is enqueued, the worker forces a fresh snapshot, + * and a card whose snapshot did not change (a metric that does not depend on + * the preference) gets a timestamp refresh and no model call. Narratives are + * re-warmed only where one already exists. Best-effort and fire-and-forget: + * the preference write never waits on it and never fails because of it. + */ +import { prisma } from "@/lib/db"; +import { enqueueNarrativeWarm } from "@/lib/jobs/period-narrative-shared"; +import { enqueueStatusRefillForUser } from "@/lib/insights/status-invalidation"; +import { normalizeLocale } from "@/lib/insights/status-shared"; +import { locales, type Locale } from "@/lib/i18n/config"; +import type { NarrativePeriod } from "@/lib/insights/narrative/period-narrative"; +import { annotate } from "@/lib/logging/context"; + +export async function refreshTextsAfterUnitChange( + userId: string, +): Promise { + try { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { locale: true }, + }); + const statusScopes = await enqueueStatusRefillForUser( + userId, + normalizeLocale(user?.locale), + ); + + const narratives = await prisma.insightNarrative.findMany({ + where: { userId }, + select: { period: true, locale: true }, + }); + let narrativeCount = 0; + for (const row of narratives) { + if (row.period !== "week" && row.period !== "month") continue; + if (!(locales as readonly string[]).includes(row.locale)) continue; + void enqueueNarrativeWarm({ + userId, + period: row.period as NarrativePeriod, + locale: row.locale as Locale, + }); + narrativeCount++; + } + + annotate({ + action: { name: "insights.unit-change.refresh" }, + meta: { status_scopes: statusScopes, narratives: narrativeCount }, + }); + } catch { + // Best-effort: the nightly warm and the next ingest still converge the + // texts; a failed enqueue must not surface on the preference write. + } +}