mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
fix(insights): hand the briefing its figures in the reader's units
The daily briefing serialised the aggregated feature set as computed: weight, grip strength and the weight target in kilograms, waist in centimetres, glucose and the signals of the day in mg/dL (the glucose signal carried no unit at all), workout distances in kilometres. A reader on pounds or mmol/L got a briefing in units they do not use. One step now converts the feature set after all canonical arithmetic, on both the nightly and the on-demand path: absolute values through the full transform, differences and slopes through the factor alone, every converted block labelled, workout distances under unit-neutral keys. The prompt, the stored fingerprint and the grounding check all read the converted set, so a unit switch moves the fingerprint and the grounding check compares the reply against the figures the model was shown. The weight target sentence, the comparison block and the GLP-1 plateau context quote weight in the reader's unit; the comparison block also labelled sleep as hours while carrying minutes. A unit switch now also re-warms a cached briefing alongside the status notes and narratives. Refs #1067
This commit is contained in:
1 parent
d1b2539b1f
commit
a7e6b89672
15 files changed
+794
-21
No files matched your search
@@ -12,7 +12,7 @@
|
||||
* - 429 from the provider → 429 (passthrough, not 5xx)
|
||||
* - any other status → 422 (generic provider-connection failure)
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, onTestFinished } from "vitest";
|
||||
|
||||
// Mocks must be hoisted before importing the route.
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
@@ -171,6 +171,7 @@ import {
|
||||
FeaturesPayloadTooLargeError,
|
||||
} from "@/lib/insights/features";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { buildUserPrompt } from "@/lib/ai/prompts/insight-system-prompt";
|
||||
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -460,6 +461,44 @@ describe("POST /api/insights/generate — cache write (v1.16.8)", () => {
|
||||
expect(args.data.insightsCachedLocale).toBe("en");
|
||||
});
|
||||
|
||||
it("serialises the feature set in the reader's units", async () => {
|
||||
makeWorkingProvider();
|
||||
const user = {
|
||||
insightsPrivacyMode: "aggregated",
|
||||
insightsCachedAt: null,
|
||||
insightsCachedText: null,
|
||||
locale: "en",
|
||||
unitPreference: "imperial",
|
||||
glucoseUnit: "mmol/L",
|
||||
};
|
||||
const findUnique = vi.mocked(prisma.user.findUnique);
|
||||
const original = findUnique.getMockImplementation();
|
||||
findUnique.mockResolvedValue(user as never);
|
||||
onTestFinished(() => {
|
||||
findUnique.mockImplementation(original!);
|
||||
});
|
||||
vi.mocked(extractFeatures).mockResolvedValueOnce({
|
||||
weight: { latest: 80, avg7: 80.5 },
|
||||
glucose: { latest: 126, avg30: 99 },
|
||||
} as never);
|
||||
|
||||
const res = await POST(jsonRequest({ force: true }) as never);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const featuresJson = vi.mocked(buildUserPrompt).mock.calls[0][0];
|
||||
const features = JSON.parse(featuresJson);
|
||||
expect(features.weight).toMatchObject({
|
||||
unit: "lb",
|
||||
latest: 176.4,
|
||||
avg7: 177.5,
|
||||
});
|
||||
expect(features.glucose).toMatchObject({
|
||||
unit: "mmol/L",
|
||||
latest: 7,
|
||||
avg30: 5.5,
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a stale cache write when AI profile inclusion changes during generation", async () => {
|
||||
makeWorkingProvider();
|
||||
selfContextText
|
||||
|
||||
@@ -87,6 +87,8 @@ import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { normalizeLocale } from "@/lib/insights/status-shared";
|
||||
import { hasUsableStatusProvider } from "@/lib/insights/status-provider";
|
||||
import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash";
|
||||
import { featuresInReaderUnits } from "@/lib/insights/features-units";
|
||||
import { resolveUnitPreferences } from "@/lib/measurements/display-transform";
|
||||
import { enqueueForceWarm } from "@/lib/jobs/insight-pregenerate-shared";
|
||||
// v1.28.25 — the comparison-snapshot builder was a private near-copy of
|
||||
// the lib export (drifted only in comments); the route now shares the
|
||||
@@ -340,6 +342,10 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
// v1.25 — per-user response-timeout (seconds), threaded onto the
|
||||
// generation calls so a slow self-hosted backend honours the setting.
|
||||
aiResponseTimeoutSeconds: true,
|
||||
// The reader's units; the feature set is converted into them before
|
||||
// it is serialised, hashed and checked for grounding.
|
||||
unitPreference: true,
|
||||
glucoseUnit: true,
|
||||
},
|
||||
});
|
||||
const modeAtRead = dbUser?.insightsPrivacyMode ?? "aggregated";
|
||||
@@ -610,6 +616,13 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
// BEFORE serialisation so the model never sees the dropped blocks.
|
||||
const excludeList = dbUser?.insightsExcludeMetrics ?? [];
|
||||
features = applyInsightsExcludeFilter(features, excludeList);
|
||||
// The reader's units, after all canonical arithmetic: the prompt, the
|
||||
// stored hash and the grounding check read the same converted figures.
|
||||
const units = resolveUnitPreferences({
|
||||
unitPreference: dbUser?.unitPreference,
|
||||
glucoseUnit: dbUser?.glucoseUnit,
|
||||
});
|
||||
features = featuresInReaderUnits(features, units);
|
||||
// v1.4.36 W3 T4 — drop zero-row blocks so the prompt never carries
|
||||
// labelled-empty sections (`"sleep": []`, `"medications": []`,
|
||||
// etc.). Prevents the model from narrating "there are no
|
||||
@@ -651,7 +664,11 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
comparisonSnapshot ?? undefined,
|
||||
);
|
||||
if (plateauContext) {
|
||||
userPrompt += buildGlp1PlateauPrompt(plateauContext, locale);
|
||||
userPrompt += buildGlp1PlateauPrompt(
|
||||
plateauContext,
|
||||
locale,
|
||||
units.system,
|
||||
);
|
||||
}
|
||||
// v1.15.20 — fold the user-authored "about me" self-description
|
||||
// (Settings → AI) into the briefing as a delimited, user-provided
|
||||
|
||||
@@ -263,4 +263,42 @@ describe("buildComparisonSnapshotForUser — output-equivalence to the raw-avera
|
||||
expect(measurementAggregate).not.toHaveBeenCalled();
|
||||
expect(measurementFindMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("states weight in the reader's mass unit and labels sleep in minutes", async () => {
|
||||
findUnique.mockResolvedValue({
|
||||
dashboardWidgetsJson: {
|
||||
version: 1,
|
||||
widgets: [],
|
||||
comparisonBaseline: "lastMonth",
|
||||
},
|
||||
unitPreference: "imperial",
|
||||
});
|
||||
measurementAggregate.mockImplementation(
|
||||
async (args: { where: { type: string; measuredAt: { lte?: Date } } }) =>
|
||||
args.where.type === "WEIGHT"
|
||||
? { _avg: { value: args.where.measuredAt.lte ? 82 : 80 } }
|
||||
: { _avg: { value: null } },
|
||||
);
|
||||
// Seven-hour nights, stored in minutes.
|
||||
measurementFindMany.mockResolvedValue(
|
||||
Array.from({ length: 5 }, (_, i) => ({
|
||||
measuredAt: new Date(Date.now() - (i + 2) * DAY),
|
||||
value: 420,
|
||||
sleepStage: null,
|
||||
source: "APPLE_HEALTH",
|
||||
deviceType: null,
|
||||
})),
|
||||
);
|
||||
const snapshot = await buildComparisonSnapshotForUser("u1");
|
||||
const sleep = snapshot!.metrics.find((m) => m.type === "sleep");
|
||||
expect(sleep).toMatchObject({ unit: "min", currentAvg: 420 });
|
||||
const weight = snapshot!.metrics.find((m) => m.type === "weight");
|
||||
expect(weight).toMatchObject({
|
||||
unit: "lb",
|
||||
currentAvg: 176.4,
|
||||
baselineAvg: 180.8,
|
||||
delta: -4.4,
|
||||
deltaPercent: -2.4,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -94,10 +94,18 @@ vi.mock("@/lib/cache/invalidate", () => ({
|
||||
import { generateComprehensiveInsight } from "../comprehensive-generate";
|
||||
import { hashInsightSnapshot } from "../snapshot-hash";
|
||||
import { compactSections } from "@/lib/ai/prompts/compact-sections";
|
||||
import { featuresInReaderUnits } from "../features-units";
|
||||
import { DEFAULT_UNIT_PREFERENCES } from "@/lib/measurements/display-transform";
|
||||
|
||||
const FEATURES = { weight: { count: 12, latest: 81.4, mean30: 82.1 } };
|
||||
// Hashed as the prompt reads it: in the reader's units (defaults here).
|
||||
const FEATURES_HASH = hashInsightSnapshot({
|
||||
features: compactSections(FEATURES as unknown as Record<string, unknown>),
|
||||
features: compactSections(
|
||||
featuresInReaderUnits(
|
||||
FEATURES as never,
|
||||
DEFAULT_UNIT_PREFERENCES,
|
||||
) as unknown as Record<string, unknown>,
|
||||
),
|
||||
aboutMe: null,
|
||||
comparisonBaseline: "none",
|
||||
generationLocale: "de",
|
||||
|
||||
@@ -94,6 +94,8 @@ vi.mock("@/lib/ai/coach/about-me", () => ({
|
||||
import { generateComprehensiveInsight } from "../comprehensive-generate";
|
||||
import { hashInsightSnapshot } from "../snapshot-hash";
|
||||
import { compactSections } from "@/lib/ai/prompts/compact-sections";
|
||||
import { featuresInReaderUnits } from "../features-units";
|
||||
import { DEFAULT_UNIT_PREFERENCES } from "@/lib/measurements/display-transform";
|
||||
|
||||
/** A small data-bearing feature set that survives `compactSections`. */
|
||||
const FEATURES = {
|
||||
@@ -103,9 +105,16 @@ const FEATURES = {
|
||||
// (null here — the about-me module is mocked to no text), the
|
||||
// comparison-baseline setting ("none" — the mocked user row carries no
|
||||
// dashboardWidgetsJson) and the generation locale, matching the
|
||||
// composite shape both the gate and the POST route hash.
|
||||
// composite shape both the gate and the POST route hash. The features are
|
||||
// hashed as the prompt reads them: in the reader's units (the mocked user
|
||||
// row carries no preference, so the defaults).
|
||||
const FEATURES_HASH = hashInsightSnapshot({
|
||||
features: compactSections(FEATURES as unknown as Record<string, unknown>),
|
||||
features: compactSections(
|
||||
featuresInReaderUnits(
|
||||
FEATURES as never,
|
||||
DEFAULT_UNIT_PREFERENCES,
|
||||
) as unknown as Record<string, unknown>,
|
||||
),
|
||||
aboutMe: null,
|
||||
comparisonBaseline: "none",
|
||||
generationLocale: "de",
|
||||
@@ -305,6 +314,61 @@ describe("generateComprehensiveInsight — content-hash gate (v1.16.8)", () => {
|
||||
expect(auditDeleteMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hands the model the feature set in the reader's units and fingerprints that", async () => {
|
||||
makeByokChain();
|
||||
findUnique.mockResolvedValue({
|
||||
insightsPrivacyMode: "aggregated",
|
||||
insightsCachedAt: new Date(Date.now() - 26 * 60 * 60 * 1000),
|
||||
insightsCachedText: JSON.stringify({ dailyBriefing: { p: "old" } }),
|
||||
insightsExcludeMetrics: [],
|
||||
insightsSnapshotHash: FEATURES_HASH,
|
||||
unitPreference: "imperial",
|
||||
glucoseUnit: "mmol/L",
|
||||
});
|
||||
runRawCompletionWithFallback.mockResolvedValue({
|
||||
result: {
|
||||
content: JSON.stringify({ dailyBriefing: { p: "new" } }),
|
||||
tokensUsed: 10,
|
||||
providerType: "openai",
|
||||
model: "m",
|
||||
},
|
||||
workingProvider: { providerType: "openai" },
|
||||
fallbackHops: [],
|
||||
});
|
||||
|
||||
// The kilogram fingerprint no longer matches: a unit switch regenerates.
|
||||
const outcome = await generateComprehensiveInsight("u1", {
|
||||
locale: "de",
|
||||
force: true,
|
||||
});
|
||||
expect(outcome).toEqual({ status: "generated", providerType: "openai" });
|
||||
|
||||
const prompt = JSON.stringify(runRawCompletionWithFallback.mock.calls[0]);
|
||||
expect(prompt).toContain('\\"unit\\": \\"lb\\"');
|
||||
expect(prompt).toContain("179.5");
|
||||
expect(prompt).not.toContain("81.4");
|
||||
|
||||
const write = userUpdate.mock.calls.find(
|
||||
(c) =>
|
||||
(c[0] as { data: Record<string, unknown> }).data.insightsCachedText !==
|
||||
undefined,
|
||||
);
|
||||
const data = (write![0] as { data: Record<string, unknown> }).data;
|
||||
expect(data.insightsSnapshotHash).toBe(
|
||||
hashInsightSnapshot({
|
||||
features: compactSections(
|
||||
featuresInReaderUnits(FEATURES as never, {
|
||||
system: "imperial",
|
||||
glucoseUnit: "mmol/L",
|
||||
}) as unknown as Record<string, unknown>,
|
||||
),
|
||||
aboutMe: null,
|
||||
comparisonBaseline: "none",
|
||||
generationLocale: "de",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("a fresh cache tagged with another language does not short-circuit an unforced run", async () => {
|
||||
findUnique.mockResolvedValue({
|
||||
insightsPrivacyMode: "aggregated",
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* The feature set a briefing or the Coach reads is stated in the reader's
|
||||
* units: absolute values through the full transform, differences and slopes
|
||||
* through the factor alone, every converted block labelled.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { AggregatedFeatures } from "../features";
|
||||
import { featuresInReaderUnits } from "../features-units";
|
||||
|
||||
const coverage = { count: 10 } as unknown as NonNullable<
|
||||
AggregatedFeatures["weight"]
|
||||
>["coverage"];
|
||||
|
||||
const FEATURES = {
|
||||
weight: {
|
||||
latest: 80,
|
||||
avg7: 80,
|
||||
avg30: 81,
|
||||
avg90: null,
|
||||
allTimeAvg: 82,
|
||||
allTimeMin: 70,
|
||||
allTimeMax: 90,
|
||||
slope30: -0.1,
|
||||
outlierCount: 0,
|
||||
bmi: 24.7,
|
||||
coverage,
|
||||
target: {
|
||||
min: 65,
|
||||
max: 70,
|
||||
unit: "kg",
|
||||
position: "above",
|
||||
progress: "losing",
|
||||
reading: "The person set their own weight target at 65–70 kg …",
|
||||
},
|
||||
},
|
||||
glucose: {
|
||||
avg7: 108,
|
||||
avg30: 99,
|
||||
avg90: null,
|
||||
latest: 126,
|
||||
latestDaysAgo: 0,
|
||||
slope30: 1.8,
|
||||
coverage,
|
||||
},
|
||||
signalsOfDay: [
|
||||
{
|
||||
metric: "weight",
|
||||
label: "weight",
|
||||
unit: "kg",
|
||||
latest: 80,
|
||||
latestDaysAgo: 0,
|
||||
avg7: 80.5,
|
||||
avg30: 81,
|
||||
deltaVs7: -0.5,
|
||||
deltaVs30: -1,
|
||||
spread30: 0.6,
|
||||
outsideNormalSwing: true,
|
||||
emergingTrend: "falling",
|
||||
recentAnomaly: { kind: "trough", value: 79, anomalyDaysAgo: 2 },
|
||||
},
|
||||
{
|
||||
metric: "glucose",
|
||||
label: "blood glucose",
|
||||
latest: 90,
|
||||
latestDaysAgo: 0,
|
||||
avg7: 99,
|
||||
avg30: 108,
|
||||
deltaVs7: -9,
|
||||
deltaVs30: -18,
|
||||
spread30: 9,
|
||||
outsideNormalSwing: true,
|
||||
emergingTrend: "flat",
|
||||
recentAnomaly: null,
|
||||
},
|
||||
{
|
||||
metric: "bp",
|
||||
label: "blood pressure (systolic)",
|
||||
unit: "mmHg",
|
||||
latest: 128,
|
||||
latestDaysAgo: 0,
|
||||
avg7: 125,
|
||||
avg30: 124,
|
||||
deltaVs7: 3,
|
||||
deltaVs30: 4,
|
||||
spread30: 5,
|
||||
outsideNormalSwing: false,
|
||||
emergingTrend: "flat",
|
||||
recentAnomaly: null,
|
||||
},
|
||||
],
|
||||
historicalComparison: {
|
||||
weight: { current7dAvg: 80, previous30dAvg: 82, change: -2 },
|
||||
},
|
||||
workouts: {
|
||||
last7: { count: 2, totalDurationMin: 90, totalDistanceKm: 10 },
|
||||
last30: { count: 6, totalDurationMin: 300, totalDistanceKm: null },
|
||||
latest: {
|
||||
sportType: "running",
|
||||
daysAgo: 1,
|
||||
durationMin: 45,
|
||||
distanceKm: 5,
|
||||
},
|
||||
},
|
||||
context: {},
|
||||
} as unknown as AggregatedFeatures;
|
||||
|
||||
describe("featuresInReaderUnits", () => {
|
||||
it("states weight in pounds for an imperial reader, labelled", () => {
|
||||
const out = featuresInReaderUnits(FEATURES, {
|
||||
system: "imperial",
|
||||
glucoseUnit: "mg/dL",
|
||||
});
|
||||
expect(out.weight).toMatchObject({
|
||||
unit: "lb",
|
||||
latest: 176.4,
|
||||
avg30: 178.6,
|
||||
avg90: null,
|
||||
allTimeMin: 154.3,
|
||||
slope30: -0.2,
|
||||
bmi: 24.7,
|
||||
});
|
||||
// The target band is quoted in pounds and still judged in kilograms.
|
||||
expect(out.weight?.target).toMatchObject({
|
||||
min: 143.3,
|
||||
max: 154.3,
|
||||
unit: "lb",
|
||||
position: "above",
|
||||
});
|
||||
expect(out.weight?.target?.reading).toContain("143.3–154.3 lb");
|
||||
expect(out.historicalComparison?.weight).toMatchObject({
|
||||
current7dAvg: 176.4,
|
||||
change: -4.4,
|
||||
unit: "lb",
|
||||
});
|
||||
});
|
||||
|
||||
it("converts every figure of a weight signal, deltas by the factor alone", () => {
|
||||
const [weight, glucose, bp] = featuresInReaderUnits(FEATURES, {
|
||||
system: "imperial",
|
||||
glucoseUnit: "mmol/L",
|
||||
}).signalsOfDay!;
|
||||
expect(weight).toMatchObject({
|
||||
unit: "lb",
|
||||
latest: 176.4,
|
||||
deltaVs7: -1.1,
|
||||
spread30: 1.3,
|
||||
recentAnomaly: { value: 174.2 },
|
||||
});
|
||||
expect(glucose).toMatchObject({
|
||||
unit: "mmol/L",
|
||||
latest: 5,
|
||||
avg30: 6,
|
||||
deltaVs30: -1,
|
||||
});
|
||||
expect(bp).toEqual(FEATURES.signalsOfDay![2]);
|
||||
});
|
||||
|
||||
it("states glucose in mmol/L for a mmol/L reader", () => {
|
||||
const out = featuresInReaderUnits(FEATURES, {
|
||||
system: "metric",
|
||||
glucoseUnit: "mmol/L",
|
||||
});
|
||||
expect(out.glucose).toMatchObject({
|
||||
unit: "mmol/L",
|
||||
avg7: 6,
|
||||
avg30: 5.5,
|
||||
latest: 7,
|
||||
slope30: 0.1,
|
||||
});
|
||||
});
|
||||
|
||||
it("states workout distance in the reader's unit under unit-neutral keys", () => {
|
||||
const out = featuresInReaderUnits(FEATURES, {
|
||||
system: "imperial",
|
||||
glucoseUnit: "mg/dL",
|
||||
}) as unknown as {
|
||||
workouts: Record<string, Record<string, unknown> | string>;
|
||||
};
|
||||
expect(out.workouts.distanceUnit).toBe("mi");
|
||||
expect(out.workouts.last7).toMatchObject({ totalDistance: 6.21 });
|
||||
expect(out.workouts.last7).not.toHaveProperty("totalDistanceKm");
|
||||
expect(out.workouts.last30).toMatchObject({ totalDistance: null });
|
||||
expect(out.workouts.latest).toMatchObject({ distance: 3.11 });
|
||||
});
|
||||
|
||||
it("keeps every number for a metric mg/dL reader and only adds labels", () => {
|
||||
const out = featuresInReaderUnits(FEATURES, {
|
||||
system: "metric",
|
||||
glucoseUnit: "mg/dL",
|
||||
});
|
||||
expect(out.weight).toMatchObject({ unit: "kg", latest: 80, avg30: 81 });
|
||||
expect(out.glucose).toMatchObject({ unit: "mg/dL", avg7: 108 });
|
||||
expect(out.signalsOfDay![1]).toMatchObject({ unit: "mg/dL", latest: 90 });
|
||||
});
|
||||
|
||||
it("leaves the input untouched", () => {
|
||||
const before = JSON.stringify(FEATURES);
|
||||
featuresInReaderUnits(FEATURES, {
|
||||
system: "imperial",
|
||||
glucoseUnit: "mmol/L",
|
||||
});
|
||||
expect(JSON.stringify(FEATURES)).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -392,8 +392,9 @@ describe("extractFeatures — weight target direction (#1006)", () => {
|
||||
prismaMock.measurement.findMany.mockResolvedValue(weightRows);
|
||||
const f = await extractFeatures("user-1", false);
|
||||
expect(f.weight?.target).toMatchObject({
|
||||
minKg: 65,
|
||||
maxKg: 70,
|
||||
min: 65,
|
||||
max: 70,
|
||||
unit: "kg",
|
||||
position: "below",
|
||||
progress: "gaining",
|
||||
});
|
||||
|
||||
@@ -215,6 +215,23 @@ describe("glp1-plateau", () => {
|
||||
expect(prompt).toContain("GRUNDREGEL 13");
|
||||
});
|
||||
|
||||
it("states the weight change in pounds for an imperial reader", () => {
|
||||
const ctx = {
|
||||
drug: "Mounjaro",
|
||||
doseValue: 7.5,
|
||||
doseUnit: "mg",
|
||||
doseSince: "2026-04-01",
|
||||
daysOnDose: 30,
|
||||
weightDeltaKg: -0.4,
|
||||
readingsCount: 9,
|
||||
};
|
||||
const en = buildGlp1PlateauPrompt(ctx, "en", "imperial");
|
||||
expect(en).toContain("shifted by -0.9 lb");
|
||||
expect(en).not.toMatch(/\bkg\b/);
|
||||
const de = buildGlp1PlateauPrompt(ctx, "de", "imperial");
|
||||
expect(de).toContain("nur um -0.9 lb verändert");
|
||||
});
|
||||
|
||||
it("computes week number from days on dose", () => {
|
||||
const prompt = buildGlp1PlateauPrompt(
|
||||
{
|
||||
|
||||
@@ -12,10 +12,14 @@ vi.mock("@/lib/insights/status-invalidation", () => ({
|
||||
vi.mock("@/lib/jobs/period-narrative-shared", () => ({
|
||||
enqueueNarrativeWarm: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/jobs/insight-pregenerate-shared", () => ({
|
||||
enqueueForceWarm: vi.fn(),
|
||||
}));
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { enqueueStatusRefillForUser } from "@/lib/insights/status-invalidation";
|
||||
import { enqueueNarrativeWarm } from "@/lib/jobs/period-narrative-shared";
|
||||
import { enqueueForceWarm } from "@/lib/jobs/insight-pregenerate-shared";
|
||||
import { refreshTextsAfterUnitChange } from "../unit-change-refresh";
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -51,6 +55,34 @@ describe("refreshTextsAfterUnitChange", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("re-warms a cached briefing in the language it was written in", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
locale: "de",
|
||||
insightsCachedAt: new Date(),
|
||||
insightsCachedLocale: "en",
|
||||
} as never);
|
||||
vi.mocked(prisma.insightNarrative.findMany).mockResolvedValue([]);
|
||||
|
||||
await refreshTextsAfterUnitChange("user-1");
|
||||
|
||||
expect(enqueueForceWarm).toHaveBeenCalledWith({
|
||||
userId: "user-1",
|
||||
locale: "en",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves the briefing alone when none was ever written", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
locale: "de",
|
||||
insightsCachedAt: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.insightNarrative.findMany).mockResolvedValue([]);
|
||||
|
||||
await refreshTextsAfterUnitChange("user-1");
|
||||
|
||||
expect(enqueueForceWarm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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(
|
||||
|
||||
@@ -41,6 +41,13 @@ import {
|
||||
buildBriefingIllnessCyclePrompt,
|
||||
} from "@/lib/insights/illness-cycle-briefing";
|
||||
import { compactSections } from "@/lib/ai/prompts/compact-sections";
|
||||
import { featuresInReaderUnits } from "@/lib/insights/features-units";
|
||||
import {
|
||||
applyDisplayTransform,
|
||||
applyDisplayTransformDelta,
|
||||
getReadingTransform,
|
||||
resolveUnitPreferences,
|
||||
} from "@/lib/measurements/display-transform";
|
||||
import {
|
||||
detectGlp1Plateau,
|
||||
buildGlp1PlateauPrompt,
|
||||
@@ -324,7 +331,11 @@ export async function buildComparisonSnapshotForUser(
|
||||
): Promise<ComparisonSnapshot | null> {
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { dashboardWidgetsJson: true },
|
||||
select: {
|
||||
dashboardWidgetsJson: true,
|
||||
unitPreference: true,
|
||||
glucoseUnit: true,
|
||||
},
|
||||
});
|
||||
const layout = resolveDashboardLayout(row?.dashboardWidgetsJson);
|
||||
const baseline: ComparisonBaseline = layout.comparisonBaseline ?? "none";
|
||||
@@ -339,15 +350,23 @@ export async function buildComparisonSnapshotForUser(
|
||||
SLEEP_DURATION: "sleep",
|
||||
ACTIVITY_STEPS: "steps",
|
||||
};
|
||||
// Weight is converted into the reader's mass unit below; the rest read the
|
||||
// same for everyone. Sleep comes off the night reconstruction in minutes.
|
||||
const typeUnits: Record<string, string> = {
|
||||
WEIGHT: "kg",
|
||||
BLOOD_PRESSURE_SYS: "mmHg",
|
||||
BLOOD_PRESSURE_DIA: "mmHg",
|
||||
PULSE: "bpm",
|
||||
BODY_FAT: "%",
|
||||
SLEEP_DURATION: "h",
|
||||
SLEEP_DURATION: "min",
|
||||
ACTIVITY_STEPS: "",
|
||||
};
|
||||
const mass = getReadingTransform(
|
||||
"WEIGHT",
|
||||
resolveUnitPreferences({
|
||||
unitPreference: row?.unitPreference,
|
||||
glucoseUnit: row?.glucoseUnit,
|
||||
}),
|
||||
);
|
||||
const types = Object.keys(typeToSnapshotKey) as MeasurementType[];
|
||||
|
||||
// The snapshot only ever reads two of summarize()'s means per call: avg30
|
||||
@@ -460,6 +479,23 @@ export async function buildComparisonSnapshotForUser(
|
||||
delta !== null && baselineAvg !== null && baselineAvg !== 0
|
||||
? Math.round((delta / Math.abs(baselineAvg)) * 100 * 10) / 10
|
||||
: null;
|
||||
if (type === "WEIGHT") {
|
||||
return {
|
||||
type: typeToSnapshotKey[type],
|
||||
currentAvg:
|
||||
currentAvg === null
|
||||
? null
|
||||
: applyDisplayTransform(currentAvg, mass),
|
||||
baselineAvg:
|
||||
baselineAvg === null
|
||||
? null
|
||||
: applyDisplayTransform(baselineAvg, mass),
|
||||
delta:
|
||||
delta === null ? null : applyDisplayTransformDelta(delta, mass),
|
||||
deltaPercent,
|
||||
unit: mass.displayUnit,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: typeToSnapshotKey[type] ?? type,
|
||||
currentAvg,
|
||||
@@ -646,6 +682,10 @@ export async function generateComprehensiveInsight(
|
||||
// provider call below so a raised value for a slow self-hosted backend
|
||||
// actually applies to the briefing, not just to Coach.
|
||||
aiResponseTimeoutSeconds: true,
|
||||
// The reader's units: the feature set is converted into them before it
|
||||
// is hashed and serialised, so a unit switch regenerates the briefing.
|
||||
unitPreference: true,
|
||||
glucoseUnit: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -800,6 +840,14 @@ export async function generateComprehensiveInsight(
|
||||
displayName: dbUser?.displayName ?? null,
|
||||
};
|
||||
features = applyInsightsExcludeFilter(features, excludeList);
|
||||
// Every figure below the canonical arithmetic is stated in the reader's
|
||||
// units: the prompt, the content hash and the grounding check all read the
|
||||
// converted set, so they agree with each other and with the reader.
|
||||
const units = resolveUnitPreferences({
|
||||
unitPreference: dbUser?.unitPreference,
|
||||
glucoseUnit: dbUser?.glucoseUnit,
|
||||
});
|
||||
features = featuresInReaderUnits(features, units);
|
||||
const compactFeatures = compactSections(
|
||||
features as unknown as Record<string, unknown>,
|
||||
);
|
||||
@@ -863,7 +911,7 @@ export async function generateComprehensiveInsight(
|
||||
comparisonSnapshot ?? undefined,
|
||||
);
|
||||
if (plateauContext) {
|
||||
userPrompt += buildGlp1PlateauPrompt(plateauContext, locale);
|
||||
userPrompt += buildGlp1PlateauPrompt(plateauContext, locale, units.system);
|
||||
}
|
||||
if (aboutMe) {
|
||||
userPrompt += buildAboutMeInsightBlock(aboutMe, locale);
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* The aggregated feature set as a model reads it: every quantity whose unit
|
||||
* depends on a preference converted into the reader's units, and labelled.
|
||||
*
|
||||
* `extractFeatures` computes in canonical units (kilograms, centimetres,
|
||||
* metres, mg/dL) because the classifications, the correlations and the
|
||||
* cached feature set are shared by every surface. The briefing and the
|
||||
* Coach then serialised that canonical set straight into their prompts, so a
|
||||
* reader on pounds or mmol/L got a narrative in kilograms or mg/dL. This is
|
||||
* the one step between the two: it runs after all canonical arithmetic and
|
||||
* before the snapshot is hashed, serialised and checked for grounding, so
|
||||
* the hash moves with a unit switch and the grounding check compares the
|
||||
* model's numbers against the same figures the model was shown.
|
||||
*
|
||||
* Absolute values take the full transform, differences and slopes the
|
||||
* factor alone. A metric reader on mg/dL gets every number back unchanged,
|
||||
* with a unit label added.
|
||||
*/
|
||||
import {
|
||||
applyDisplayTransform,
|
||||
applyDisplayTransformDelta,
|
||||
getQuantityTransform,
|
||||
getReadingTransform,
|
||||
type DisplayTransform,
|
||||
type UnitPreferences,
|
||||
} from "@/lib/measurements/display-transform";
|
||||
import { buildWeightTargetFeature } from "@/lib/targets/weight-trend";
|
||||
|
||||
import type {
|
||||
AggregatedFeatures,
|
||||
BucketedSeries,
|
||||
RawFeatures,
|
||||
} from "./features";
|
||||
import type { SignalOfDay } from "./signals-of-day";
|
||||
|
||||
type Num = number | null;
|
||||
|
||||
function abs(value: Num, transform: DisplayTransform): Num {
|
||||
return typeof value === "number"
|
||||
? applyDisplayTransform(value, transform)
|
||||
: value;
|
||||
}
|
||||
|
||||
function delta(value: Num, transform: DisplayTransform): Num {
|
||||
return typeof value === "number"
|
||||
? applyDisplayTransformDelta(value, transform)
|
||||
: value;
|
||||
}
|
||||
|
||||
/** The measurement type each signal of the day is read from. */
|
||||
const SIGNAL_TYPE: Partial<Record<SignalOfDay["metric"], string>> = {
|
||||
weight: "WEIGHT",
|
||||
glucose: "BLOOD_GLUCOSE",
|
||||
};
|
||||
|
||||
/** One signal of the day in the reader's units. */
|
||||
export function signalInReaderUnits(
|
||||
signal: SignalOfDay,
|
||||
units: UnitPreferences,
|
||||
): SignalOfDay {
|
||||
const type = SIGNAL_TYPE[signal.metric];
|
||||
if (!type) return signal;
|
||||
const t = getReadingTransform(type, units);
|
||||
return {
|
||||
...signal,
|
||||
unit: t.displayUnit,
|
||||
latest: applyDisplayTransform(signal.latest, t),
|
||||
avg7: abs(signal.avg7, t),
|
||||
avg30: abs(signal.avg30, t),
|
||||
deltaVs7: delta(signal.deltaVs7, t),
|
||||
deltaVs30: delta(signal.deltaVs30, t),
|
||||
spread30: delta(signal.spread30, t),
|
||||
recentAnomaly: signal.recentAnomaly
|
||||
? {
|
||||
...signal.recentAnomaly,
|
||||
value: applyDisplayTransform(signal.recentAnomaly.value, t),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** The weight aggregate in the reader's units, its target included. */
|
||||
export function weightFeatureInReaderUnits(
|
||||
weight: NonNullable<AggregatedFeatures["weight"]>,
|
||||
units: UnitPreferences,
|
||||
): NonNullable<AggregatedFeatures["weight"]> & { unit: string } {
|
||||
const t = getReadingTransform("WEIGHT", units);
|
||||
// The target is rebuilt from the canonical band and the canonical trend
|
||||
// reference, so its position is judged exactly as before and only the
|
||||
// band it quotes changes unit.
|
||||
const target = weight.target
|
||||
? (buildWeightTargetFeature(
|
||||
{ min: weight.target.min, max: weight.target.max },
|
||||
{ avg7: weight.avg7, latest: weight.latest },
|
||||
units.system,
|
||||
) ?? undefined)
|
||||
: undefined;
|
||||
return {
|
||||
...weight,
|
||||
unit: t.displayUnit,
|
||||
latest: applyDisplayTransform(weight.latest, t),
|
||||
avg7: abs(weight.avg7, t),
|
||||
avg30: abs(weight.avg30, t),
|
||||
avg90: abs(weight.avg90, t),
|
||||
allTimeAvg: abs(weight.allTimeAvg, t),
|
||||
allTimeMin: abs(weight.allTimeMin, t),
|
||||
allTimeMax: abs(weight.allTimeMax, t),
|
||||
slope30: delta(weight.slope30, t),
|
||||
...(target ? { target } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function bucketedInReaderUnits(
|
||||
series: BucketedSeries,
|
||||
units: UnitPreferences,
|
||||
): BucketedSeries & { unit: string } {
|
||||
const t = getReadingTransform(series.type, units);
|
||||
return {
|
||||
...series,
|
||||
unit: t.displayUnit,
|
||||
buckets: series.buckets.map((bucket) => ({
|
||||
...bucket,
|
||||
mean: applyDisplayTransform(bucket.mean, t),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole feature set in the reader's units. Pure: returns a new object
|
||||
* and leaves the (possibly cached and shared) input untouched.
|
||||
*/
|
||||
export function featuresInReaderUnits<
|
||||
T extends AggregatedFeatures | RawFeatures,
|
||||
>(features: T, units: UnitPreferences): T {
|
||||
const out: AggregatedFeatures & Partial<RawFeatures> = { ...features };
|
||||
|
||||
if (features.weight) {
|
||||
out.weight = weightFeatureInReaderUnits(features.weight, units);
|
||||
}
|
||||
|
||||
if (features.gripStrength) {
|
||||
const t = getReadingTransform("GRIP_STRENGTH", units);
|
||||
out.gripStrength = {
|
||||
...features.gripStrength,
|
||||
latest: abs(features.gripStrength.latest, t),
|
||||
avg30: abs(features.gripStrength.avg30, t),
|
||||
slope30: delta(features.gripStrength.slope30, t),
|
||||
unit: t.displayUnit,
|
||||
} as AggregatedFeatures["gripStrength"];
|
||||
}
|
||||
|
||||
if (features.waist) {
|
||||
const t = getReadingTransform("WAIST_CIRCUMFERENCE", units);
|
||||
out.waist = {
|
||||
...features.waist,
|
||||
latest: abs(features.waist.latest, t),
|
||||
avg30: abs(features.waist.avg30, t),
|
||||
slope30: delta(features.waist.slope30, t),
|
||||
unit: t.displayUnit,
|
||||
} as AggregatedFeatures["waist"];
|
||||
}
|
||||
|
||||
if (features.glucose) {
|
||||
const t = getReadingTransform("BLOOD_GLUCOSE", units);
|
||||
out.glucose = {
|
||||
...features.glucose,
|
||||
avg7: abs(features.glucose.avg7, t),
|
||||
avg30: abs(features.glucose.avg30, t),
|
||||
avg90: abs(features.glucose.avg90, t),
|
||||
latest: abs(features.glucose.latest, t),
|
||||
slope30: delta(features.glucose.slope30, t),
|
||||
unit: t.displayUnit,
|
||||
} as AggregatedFeatures["glucose"];
|
||||
}
|
||||
|
||||
const weightHistory = features.historicalComparison?.weight;
|
||||
if (features.historicalComparison && weightHistory) {
|
||||
const t = getReadingTransform("WEIGHT", units);
|
||||
out.historicalComparison = {
|
||||
...features.historicalComparison,
|
||||
weight: {
|
||||
current7dAvg: abs(weightHistory.current7dAvg, t),
|
||||
previous30dAvg: abs(weightHistory.previous30dAvg, t),
|
||||
change: delta(weightHistory.change, t),
|
||||
unit: t.displayUnit,
|
||||
} as NonNullable<AggregatedFeatures["historicalComparison"]>["weight"],
|
||||
};
|
||||
}
|
||||
|
||||
if (features.signalsOfDay) {
|
||||
out.signalsOfDay = features.signalsOfDay.map((signal) =>
|
||||
signalInReaderUnits(signal, units),
|
||||
);
|
||||
}
|
||||
|
||||
if (features.workouts) {
|
||||
const t = getQuantityTransform("distance", units.system);
|
||||
// The block is computed in kilometres; the transform takes metres. The
|
||||
// keys lose their "Km" so a mile figure never sits under a kilometre
|
||||
// name.
|
||||
const distance = (value: Num) =>
|
||||
abs(typeof value === "number" ? value * 1000 : value, t);
|
||||
const { last7, last30, latest } = features.workouts;
|
||||
const period = ({ totalDistanceKm, ...rest }: typeof last7) => ({
|
||||
...rest,
|
||||
totalDistance: distance(totalDistanceKm),
|
||||
});
|
||||
out.workouts = {
|
||||
last7: period(last7),
|
||||
last30: period(last30),
|
||||
latest: latest
|
||||
? (({ distanceKm, ...rest }) => ({
|
||||
...rest,
|
||||
distance: distance(distanceKm),
|
||||
}))(latest)
|
||||
: latest,
|
||||
distanceUnit: t.displayUnit,
|
||||
} as unknown as AggregatedFeatures["workouts"];
|
||||
}
|
||||
|
||||
if ("bucketedMeasurements" in features) {
|
||||
out.bucketedMeasurements = features.bucketedMeasurements.map((series) =>
|
||||
bucketedInReaderUnits(series, units),
|
||||
);
|
||||
}
|
||||
|
||||
return out as T;
|
||||
}
|
||||
@@ -19,6 +19,12 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { sanitizeForPrompt } from "@/lib/insights/sanitize";
|
||||
import {
|
||||
applyDisplayTransformDelta,
|
||||
DEFAULT_UNIT_PREFERENCE,
|
||||
getDisplayTransform,
|
||||
type UnitPreference,
|
||||
} from "@/lib/measurements/display-transform";
|
||||
|
||||
/**
|
||||
* Trailing window (days) the detector compares weight across. Exported
|
||||
@@ -122,7 +128,11 @@ export async function detectGlp1Plateau(
|
||||
export function buildGlp1PlateauPrompt(
|
||||
ctx: Glp1PlateauContext,
|
||||
locale: Locale,
|
||||
/** The reader's metric/imperial choice; the weight change is stated in it. */
|
||||
system: UnitPreference = DEFAULT_UNIT_PREFERENCE,
|
||||
): string {
|
||||
const mass = getDisplayTransform("WEIGHT", system);
|
||||
const weightDelta = `${applyDisplayTransformDelta(ctx.weightDeltaKg, mass)} ${mass.displayUnit}`;
|
||||
// v1.4.25 W10 reconcile (security H-1): sanitize every user-controlled
|
||||
// string before interpolating into the LLM prompt. `Medication.name`
|
||||
// and `MedicationDoseChange.doseUnit` are free-text columns the user
|
||||
@@ -143,7 +153,7 @@ export function buildGlp1PlateauPrompt(
|
||||
|
||||
SYSTEM CONTEXT — GLP-1-PLATEAU AKTIV
|
||||
|
||||
Der Nutzer nimmt aktuell ${doseLabel} (seit ${ctx.doseSince}, ${ctx.daysOnDose} Tage). In den letzten ${PLATEAU_WINDOW_DAYS} Tagen hat sich das Gewicht nur um ${ctx.weightDeltaKg} kg verändert (n=${ctx.readingsCount} Messungen). Das ist das klinische Plateau-Muster für GLP-1-Rezeptoragonisten.
|
||||
Der Nutzer nimmt aktuell ${doseLabel} (seit ${ctx.doseSince}, ${ctx.daysOnDose} Tage). In den letzten ${PLATEAU_WINDOW_DAYS} Tagen hat sich das Gewicht nur um ${weightDelta} verändert (n=${ctx.readingsCount} Messungen). Das ist das klinische Plateau-Muster für GLP-1-Rezeptoragonisten.
|
||||
|
||||
Wenn du einen dailyBriefing-keyFinding zu diesem Plateau emittierst:
|
||||
- sourceMetric: "glp1_plateau"
|
||||
@@ -157,7 +167,7 @@ Wenn du einen dailyBriefing-keyFinding zu diesem Plateau emittierst:
|
||||
|
||||
SYSTEM CONTEXT — GLP-1 PLATEAU ACTIVE
|
||||
|
||||
The user is currently on ${doseLabel} (since ${ctx.doseSince}, ${ctx.daysOnDose} days). In the last ${PLATEAU_WINDOW_DAYS} days their weight has shifted by ${ctx.weightDeltaKg} kg (n=${ctx.readingsCount} readings). That is the clinical plateau pattern for GLP-1 receptor agonists.
|
||||
The user is currently on ${doseLabel} (since ${ctx.doseSince}, ${ctx.daysOnDose} days). In the last ${PLATEAU_WINDOW_DAYS} days their weight has shifted by ${weightDelta} (n=${ctx.readingsCount} readings). That is the clinical plateau pattern for GLP-1 receptor agonists.
|
||||
|
||||
If you emit a dailyBriefing keyFinding for this plateau:
|
||||
- sourceMetric: "glp1_plateau"
|
||||
|
||||
@@ -12,11 +12,15 @@
|
||||
* 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:
|
||||
* re-warmed only where one already exists. The daily briefing (and with it
|
||||
* the Coach-adjacent overview text) is re-warmed when one is cached: its
|
||||
* fingerprint covers the converted feature set, so the forced warm writes a
|
||||
* new one in the new unit. 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 { enqueueForceWarm } from "@/lib/jobs/insight-pregenerate-shared";
|
||||
import { enqueueStatusRefillForUser } from "@/lib/insights/status-invalidation";
|
||||
import { normalizeLocale } from "@/lib/insights/status-shared";
|
||||
import { locales, type Locale } from "@/lib/i18n/config";
|
||||
@@ -29,7 +33,11 @@ export async function refreshTextsAfterUnitChange(
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { locale: true },
|
||||
select: {
|
||||
locale: true,
|
||||
insightsCachedAt: true,
|
||||
insightsCachedLocale: true,
|
||||
},
|
||||
});
|
||||
const statusScopes = await enqueueStatusRefillForUser(
|
||||
userId,
|
||||
@@ -52,9 +60,21 @@ export async function refreshTextsAfterUnitChange(
|
||||
narrativeCount++;
|
||||
}
|
||||
|
||||
const briefing = user?.insightsCachedAt != null;
|
||||
if (briefing) {
|
||||
void enqueueForceWarm({
|
||||
userId,
|
||||
locale: normalizeLocale(user?.insightsCachedLocale ?? user?.locale),
|
||||
});
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "insights.unit-change.refresh" },
|
||||
meta: { status_scopes: statusScopes, narratives: narrativeCount },
|
||||
meta: {
|
||||
status_scopes: statusScopes,
|
||||
narratives: narrativeCount,
|
||||
briefing,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Best-effort: the nightly warm and the next ingest still converge the
|
||||
|
||||
@@ -111,3 +111,30 @@ describe("buildWeightTargetFeature", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildWeightTargetFeature — the reader's mass unit", () => {
|
||||
it("quotes the band in pounds for an imperial reader and judges it in kilograms", () => {
|
||||
const feature = buildWeightTargetFeature(
|
||||
{ min: 65, max: 70 },
|
||||
{ avg7: 66, latest: 66 },
|
||||
"imperial",
|
||||
);
|
||||
expect(feature).toMatchObject({
|
||||
min: 143.3,
|
||||
max: 154.3,
|
||||
unit: "lb",
|
||||
position: "inside",
|
||||
});
|
||||
expect(feature?.reading).toContain("143.3–154.3 lb");
|
||||
expect(feature?.reading).not.toContain("kg");
|
||||
});
|
||||
|
||||
it("keeps kilograms for a metric reader", () => {
|
||||
const feature = buildWeightTargetFeature(
|
||||
{ min: 65, max: 70 },
|
||||
{ avg7: 72, latest: 72 },
|
||||
);
|
||||
expect(feature).toMatchObject({ min: 65, max: 70, unit: "kg" });
|
||||
expect(feature?.reading).toContain("65–70 kg");
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,12 @@
|
||||
* Pure and client-safe: no DB read, no clock.
|
||||
*/
|
||||
import type { TrendDirectionSentiment } from "@/lib/insights/trend-sentiment";
|
||||
import {
|
||||
applyDisplayTransform,
|
||||
DEFAULT_UNIT_PREFERENCE,
|
||||
getDisplayTransform,
|
||||
type UnitPreference,
|
||||
} from "@/lib/measurements/display-transform";
|
||||
|
||||
/** Where a weight sits against the person's own target band. */
|
||||
export type WeightTargetPosition = "below" | "inside" | "above";
|
||||
@@ -99,8 +105,12 @@ export function resolveWeightTrend(
|
||||
* colour, the prose makes no promise about which way is better).
|
||||
*/
|
||||
export interface WeightTargetFeature {
|
||||
minKg: number;
|
||||
maxKg: number;
|
||||
/** The band's lower edge, in `unit`. */
|
||||
min: number;
|
||||
/** The band's upper edge, in `unit`. */
|
||||
max: number;
|
||||
/** The reader's mass unit; the band and the reading sentence are in it. */
|
||||
unit: string;
|
||||
position: WeightTargetPosition;
|
||||
progress: "gaining" | "losing" | "holding steady";
|
||||
reading: string;
|
||||
@@ -118,13 +128,21 @@ const PROGRESS_BY_POSITION: Record<
|
||||
export function buildWeightTargetFeature(
|
||||
target: { min: number; max: number } | null,
|
||||
summary: { avg7: number | null; latest: number | null } | null | undefined,
|
||||
/**
|
||||
* The reader's metric/imperial choice. The position is judged on the
|
||||
* canonical kilograms either way; only the band the text quotes converts.
|
||||
*/
|
||||
system: UnitPreference = DEFAULT_UNIT_PREFERENCE,
|
||||
): WeightTargetFeature | null {
|
||||
const position = weightTargetPosition(
|
||||
target,
|
||||
weightTrendReferenceKg(summary),
|
||||
);
|
||||
if (!target || !position) return null;
|
||||
const band = `${target.min}–${target.max} kg`;
|
||||
const transform = getDisplayTransform("WEIGHT", system);
|
||||
const min = applyDisplayTransform(target.min, transform);
|
||||
const max = applyDisplayTransform(target.max, transform);
|
||||
const band = `${min}–${max} ${transform.displayUnit}`;
|
||||
const reading =
|
||||
position === "below"
|
||||
? `The person set their own weight target at ${band} and is currently below it, so gaining weight is progress and losing weight moves away from the goal. Judge weight changes against this target; never describe a gain as a setback or a regression.`
|
||||
@@ -132,8 +150,9 @@ export function buildWeightTargetFeature(
|
||||
? `The person set their own weight target at ${band} and is currently above it, so losing weight is progress and gaining moves away from the goal.`
|
||||
: `The person set their own weight target at ${band} and is currently inside it, so holding steady is progress; a small move either way within the band is not a setback.`;
|
||||
return {
|
||||
minKg: target.min,
|
||||
maxKg: target.max,
|
||||
min,
|
||||
max,
|
||||
unit: transform.displayUnit,
|
||||
position,
|
||||
progress: PROGRESS_BY_POSITION[position],
|
||||
reading,
|
||||
|
||||
Reference in new issue
Block a user