diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index bce702fa5..1f9fd5403 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -50735,6 +50735,35 @@ components: - sources additionalProperties: false - type: "null" + weightTrend: + description: Which way a weight change counts as progress, judged against the person's own stored target rather than + assuming a fall is good. Render the arrow, 7-day delta and comparison caption from `direction`; do not + recompute it. Optional for older cached snapshots; when absent, treat it as `up-bad`. + type: object + properties: + direction: + type: string + enum: + - up-good + - up-bad + - hold + description: "How the weight tile colours a change. `up-good`: a rise is progress (below the target band). `up-bad`: a + fall is progress (above the band, and the reading when no target is stored). `hold`: the weight is + inside the band, so a flat change is progress and a move either way is neutral, never a setback." + targetPosition: + anyOf: + - type: string + enum: + - below + - inside + - above + - type: "null" + description: Where the 7-day average (else the latest reading) sits against the person's own stored weight target. Null + when no target is stored or there is no reading. + required: + - direction + - targetPosition + additionalProperties: false required: - summaries - lastSeenByType diff --git a/src/__tests__/weight-tile-direction-guard.test.ts b/src/__tests__/weight-tile-direction-guard.test.ts new file mode 100644 index 000000000..7f246b7c3 --- /dev/null +++ b/src/__tests__/weight-tile-direction-guard.test.ts @@ -0,0 +1,68 @@ +/** + * The dashboard weight tile colours its trend by the resolved target + * direction, never by a literal (#1006). + * + * Every other tile on the strip passes a literal `directionSentiment`, because + * for resting pulse or steps the good direction is a property of the metric. + * Weight is the exception: which way is progress depends on the person's own + * target, and it is resolved on the server (`tiles.weightTrend` on the + * snapshot, `src/lib/targets/weight-trend.ts`). A literal `"up-bad"` here is + * exactly the defect the issue reported — a person below their target saw + * every gain coloured as a setback — and it would type-check and render + * without complaint, so this pins the attribute rather than the pixels. + * + * Structural rather than rendered because the dashboard page early-returns a + * loading gate under SSR, and the property is about the source: the weight + * `` names `weightTrend.direction`, or it does not. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import ts from "typescript"; +import { describe, expect, it } from "vitest"; + +const PAGE = join(process.cwd(), "src/app/page-client.tsx"); + +function weightTileDirections(): string[] { + const text = readFileSync(PAGE, "utf8"); + const file = ts.createSourceFile( + PAGE, + text, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX, + ); + const found: string[] = []; + const visit = (node: ts.Node) => { + if ( + ts.isJsxSelfClosingElement(node) && + node.tagName.getText(file) === "TrendCard" + ) { + const attrs = node.attributes.properties.filter(ts.isJsxAttribute); + const key = attrs.find((a) => a.name.getText(file) === "key"); + const isWeight = + key?.initializer && + ts.isStringLiteral(key.initializer) && + key.initializer.text === "weight"; + if (isWeight) { + const direction = attrs.find( + (a) => a.name.getText(file) === "directionSentiment", + ); + found.push(direction?.initializer?.getText(file) ?? ""); + } + } + ts.forEachChild(node, visit); + }; + visit(file); + return found; +} + +describe("dashboard weight tile direction", () => { + it("finds exactly one weight tile, so an empty match cannot pass", () => { + expect(weightTileDirections()).toHaveLength(1); + }); + + it("reads the resolved target direction rather than a literal", () => { + expect(weightTileDirections()).toEqual(["{weightTrend.direction}"]); + }); +}); diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx index f26a276a1..76f886445 100644 --- a/src/app/page-client.tsx +++ b/src/app/page-client.tsx @@ -157,6 +157,10 @@ import { mergeSlimAndThickAnalytics } from "@/lib/analytics/merge-slim-thick"; import { isWindowSufficient } from "@/lib/analytics/window-confidence"; import { buildDashboardBands, viewerBandProfile } from "@/lib/dashboard/bands"; import { resolveWeightTargetOverride } from "@/lib/analytics/effective-range"; +import { + resolveWeightTrend, + weightTrendReferenceKg, +} from "@/lib/targets/weight-trend"; import { toProfileSex } from "@/lib/profile/sex"; import { apiGet } from "@/lib/api/api-fetch"; @@ -766,24 +770,36 @@ export default function DashboardPageClient({ // client-side band at all and waits for the snapshot's, which resolve // against the record. const inSharedRecord = user?.accountAccess?.active != null; - const clientBands = useMemo( + const clientBandProfile = useMemo( () => - buildDashboardBands( - viewerBandProfile( - { - dateOfBirth: user?.dateOfBirth ? new Date(user.dateOfBirth) : null, - gender: toProfileSex(user?.gender), - heightCm: user?.heightCm ?? null, - weightTargetOverride: resolveWeightTargetOverride( - thresholdsData?.overrides ?? null, - ), - }, - inSharedRecord, - ), + viewerBandProfile( + { + dateOfBirth: user?.dateOfBirth ? new Date(user.dateOfBirth) : null, + gender: toProfileSex(user?.gender), + heightCm: user?.heightCm ?? null, + weightTargetOverride: resolveWeightTargetOverride( + thresholdsData?.overrides ?? null, + ), + }, + inSharedRecord, ), [user, thresholdsData, inSharedRecord], ); + const clientBands = useMemo( + () => buildDashboardBands(clientBandProfile), + [clientBandProfile], + ); const bands = serverBands ?? clientBands; + // v1.39 — which way a weight change counts as progress, judged against the + // stored target. The snapshot carries the server's answer; the + // snapshot-disabled fallback asks the same resolver from the same profile + // the band fallback above reads, so the two paths cannot disagree. + const weightTrend = + (snapshotEnabled ? snapshotQuery.data?.tiles.weightTrend : undefined) ?? + resolveWeightTrend( + clientBandProfile.weightTargetOverride, + weightTrendReferenceKg(w), + ); const bpTargets = bands.bpTargets; const weightRange = bands.weightRange; // The range hint tooltip prints its band bounds in the display unit, so scale @@ -1028,7 +1044,7 @@ export default function DashboardPageClient({ slope30={w?.slope30 ?? null} trend7Delta={tdd("WEIGHT", summaryToTrend7Delta(w))} icon={Activity} - directionSentiment="up-bad" + directionSentiment={weightTrend.direction} compareBaseline={compareBaseline} compareDelta={tdd("WEIGHT", tileCompareDelta(w))} staleDays={tileStaleDays("WEIGHT")} diff --git a/src/components/charts/__tests__/trend-card.test.tsx b/src/components/charts/__tests__/trend-card.test.tsx index ea6405069..1e1d9b434 100644 --- a/src/components/charts/__tests__/trend-card.test.tsx +++ b/src/components/charts/__tests__/trend-card.test.tsx @@ -68,6 +68,31 @@ describe(" directionSentiment", () => { expect(html).toContain("text-success"); }); + // #1006 — weight inside the person's own target band: steady is the win, + // and a move either way is neither celebrated nor scolded. + it("paints a hold metric that stayed flat as green", () => { + const html = render( + , + ); + expect(html).toContain("text-success"); + expect(html).not.toContain("text-warning"); + }); + + it("never paints a hold metric orange when it moves", () => { + for (const slope of [RISING, FALLING]) { + const html = render( + , + ); + expect(html).not.toContain("text-warning"); + expect(html).not.toContain("text-success"); + } + }); + it("paints up-good metric ↓ as orange", () => { const html = render( { }); }); +describe("buildDashboardSnapshot — weight trend judged against the stored target (#1006)", () => { + // The summary mock serves WEIGHT latest 80 (no avg7), so the reference + // weight is 80 kg in every case below. + beforeEach(() => { + probeRollupCoverage.mockResolvedValue(new Map([["WEIGHT", false]])); + isFullyCovered.mockReturnValue(false); + }); + + it("below the target: a gain is progress", async () => { + const snap = await buildDashboardSnapshot( + fakePrisma, + baseUser({ thresholdsJson: { WEIGHT: { min: 85, max: 90 } } }), + ); + expect(snap.tiles.weightTrend).toEqual({ + direction: "up-good", + targetPosition: "below", + }); + }); + + it("inside the target: holding steady is progress", async () => { + const snap = await buildDashboardSnapshot( + fakePrisma, + baseUser({ thresholdsJson: { WEIGHT: { min: 75, max: 82 } } }), + ); + expect(snap.tiles.weightTrend).toEqual({ + direction: "hold", + targetPosition: "inside", + }); + }); + + it("above the target: a loss is progress", async () => { + const snap = await buildDashboardSnapshot( + fakePrisma, + baseUser({ thresholdsJson: { WEIGHT: { min: 70, max: 75 } } }), + ); + expect(snap.tiles.weightTrend).toEqual({ + direction: "up-bad", + targetPosition: "above", + }); + }); + + it("no stored target keeps today's reading, even with a height-derived band", async () => { + const snap = await buildDashboardSnapshot(fakePrisma, baseUser()); + // The WHO band from height still shades the chart… + expect(snap.targetBands.weightRange).not.toBeNull(); + // …but nobody chose it, so it does not decide the direction. + expect(snap.tiles.weightTrend).toEqual({ + direction: "up-bad", + targetPosition: null, + }); + }); +}); + describe("buildDashboardSnapshot — two-phase null extras", () => { it("returns extras: null on a rollup-coverage miss and never runs the thick read", async () => { probeRollupCoverage.mockResolvedValue(new Map([["WEIGHT", false]])); diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index da3f1f631..95f527414 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -59,6 +59,11 @@ import { resolveWeightTargetOverride, } from "@/lib/analytics/effective-range"; import { deriveBpWindow90 } from "@/lib/analytics/window-confidence"; +import { + resolveWeightTrend, + weightTrendReferenceKg, + type WeightTrendJudgement, +} from "@/lib/targets/weight-trend"; import { probeRollupCoverage, type RollupCoverageMap, @@ -436,6 +441,15 @@ export interface DashboardSnapshot { * (additive contract) so older cached snapshots / fixtures stay valid. */ sleepSourceDiscrepancy?: SleepSourceDiscrepancy | null; + /** + * v1.39 — which way a weight change counts as progress, judged against + * the person's own stored target (`src/lib/targets/weight-trend.ts`). + * The weight tile colours its arrow, delta and comparison caption by + * `direction`; a client never re-derives it. Optional on the type + * (additive contract) so older cached snapshots stay valid; the live + * builder always sets it. + */ + weightTrend?: WeightTrendJudgement; }; /** Thick phase — null on a rollup-coverage miss. */ extras: DashboardSnapshotExtras | null; @@ -1413,6 +1427,8 @@ export async function buildDashboardSnapshot( const gender = toProfileSex(user.gender); + const weightTargetOverride = resolveWeightTargetOverride(user.thresholdsJson); + return { user: { username: user.displayName?.trim() || user.username, @@ -1433,7 +1449,7 @@ export async function buildDashboardSnapshot( // v1.34 — the user's own weight target, when they set one, replaces the // height-derived band on the tile + the chart. Resolved off the same // `thresholdsJson` blob the health score reads. - weightTargetOverride: resolveWeightTargetOverride(user.thresholdsJson), + weightTargetOverride, }), tiles: { summaries: slim.summaries, @@ -1446,6 +1462,12 @@ export async function buildDashboardSnapshot( // disabled sleep module carries no sleep annotation either. sleepSourceDiscrepancy: modules.sleep === false ? null : slimRaw.sleepSourceDiscrepancy, + // v1.39 — the weight tile's progress direction, judged against the + // stored target from the same summary the tile renders. + weightTrend: resolveWeightTrend( + weightTargetOverride, + weightTrendReferenceKg(slim.summaries.WEIGHT), + ), }, extras: extrasResult?.extras ?? null, medsToday, diff --git a/src/lib/insights/__tests__/features.test.ts b/src/lib/insights/__tests__/features.test.ts index 83f17d9cf..261810cf2 100644 --- a/src/lib/insights/__tests__/features.test.ts +++ b/src/lib/insights/__tests__/features.test.ts @@ -326,6 +326,39 @@ describe("extractFeatures — v1.25.1 clinical-depth aggregate blocks", () => { }); }); +describe("extractFeatures — weight target direction (#1006)", () => { + const weightRows = [ + measurementRow("WEIGHT", 60, 1), + measurementRow("WEIGHT", 59.6, 3), + measurementRow("WEIGHT", 59.2, 5), + ]; + + it("tells the model gaining is progress when the person is below their own target", async () => { + prismaMock.user.findUnique.mockResolvedValue({ + heightCm: 180, + dateOfBirth: new Date("1980-01-01"), + gender: "MALE", + thresholdsJson: { WEIGHT: { min: 65, max: 70 } }, + }); + prismaMock.measurement.findMany.mockResolvedValue(weightRows); + const f = await extractFeatures("user-1", false); + expect(f.weight?.target).toMatchObject({ + minKg: 65, + maxKg: 70, + position: "below", + progress: "gaining", + }); + expect(f.weight?.target?.reading).toMatch(/gaining weight is progress/); + }); + + it("says nothing about direction when no target is stored", async () => { + prismaMock.measurement.findMany.mockResolvedValue(weightRows); + const f = await extractFeatures("user-1", false); + expect(f.weight).toBeDefined(); + expect(f.weight?.target).toBeUndefined(); + }); +}); + describe("extractFeatures — v1.22 labs briefing block", () => { it("surfaces an abnormal (out-of-range) biomarker", async () => { prismaMock.labResult.findMany.mockResolvedValue([ diff --git a/src/lib/insights/features.ts b/src/lib/insights/features.ts index ed1fa38b7..b865f906d 100644 --- a/src/lib/insights/features.ts +++ b/src/lib/insights/features.ts @@ -53,6 +53,11 @@ import { } from "@/lib/insights/signals-of-day"; import type { RollupGranularity } from "@/generated/prisma/client"; +import { resolveWeightTargetOverride } from "@/lib/analytics/effective-range"; +import { + buildWeightTargetFeature, + type WeightTargetFeature, +} from "@/lib/targets/weight-trend"; // The briefing read blocks and the signals-of-day builder moved to // sibling modules; re-exported so every existing call site keeps // importing from here. @@ -442,6 +447,13 @@ export interface AggregatedFeatures { outlierCount: number; bmi: number | null; coverage: DataCoverage; + /** + * v1.39 (#1006) — the person's own stored weight target and which way is + * progress from where they are now. Absent when no target is stored, and + * then nothing here says which way is better. Present so a narrative + * never calls a gain a regression when gaining is exactly the goal. + */ + target?: WeightTargetFeature; }; bloodPressure?: { avgSys30: number | null; @@ -952,6 +964,7 @@ export async function extractFeatures( heightCm: true, dateOfBirth: true, gender: true, + thresholdsJson: true, }, }); @@ -1105,6 +1118,11 @@ export async function extractFeatures( bmi, coverage: computeCoverage(weightData, now), }; + const weightTarget = buildWeightTargetFeature( + resolveWeightTargetOverride(user?.thresholdsJson), + { avg7: summary.avg7, latest: summary.latest }, + ); + if (weightTarget) features.weight.target = weightTarget; } // Blood Pressure diff --git a/src/lib/insights/trend-sentiment.ts b/src/lib/insights/trend-sentiment.ts index 78e3ad1e7..f06f7e84a 100644 --- a/src/lib/insights/trend-sentiment.ts +++ b/src/lib/insights/trend-sentiment.ts @@ -16,8 +16,13 @@ * ↑ orange, ↓ green. * - `neutral` — direction carries no value judgement (the change renders * muted). + * - `hold` — the value already sits where the person wants it (weight + * inside their own target band). Staying put is progress + * and renders green; a move either way renders muted, never + * orange, because a small drift inside the band is not a + * setback. See `src/lib/targets/weight-trend.ts`. */ -export type TrendDirectionSentiment = "up-good" | "up-bad" | "neutral"; +export type TrendDirectionSentiment = "up-good" | "up-bad" | "neutral" | "hold"; /** * The resolved sentiment of a signed change under a metric's direction. @@ -33,7 +38,14 @@ export function getTrendSentiment( change: number | null | undefined, sentiment: TrendDirectionSentiment, ): TrendSentimentDirection { - if (change == null || Math.abs(change) < 0.05) return "neutral"; + if (change == null) return "neutral"; + // Steady inside the target: the one case where "no change" is the news. + // Uses the same noise floor that draws the flat arrow, so a green arrow is + // always the flat one. + if (sentiment === "hold") { + return Math.abs(change) < 0.05 ? "positive" : "neutral"; + } + if (Math.abs(change) < 0.05) return "neutral"; if (sentiment === "neutral") return "neutral"; const isUp = change > 0; const isGood = diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index 2fa522b70..0abc60452 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -1201,6 +1201,24 @@ export const dashboardSnapshotResponse = z .describe( "Non-null when two writer buckets reported clearly different asleep totals for the latest night's main session (> 45 min apart and > 20% of the larger total). Observational only — the served summary stays the winning writer's totals; clients may show a discreet 'sources disagree' hint next to the sleep tile's headline. Null when the writers agree or the sleep module is off; optional for older cached snapshots.", ), + weightTrend: z + .object({ + direction: z + .enum(["up-good", "up-bad", "hold"]) + .describe( + "How the weight tile colours a change. `up-good`: a rise is progress (below the target band). `up-bad`: a fall is progress (above the band, and the reading when no target is stored). `hold`: the weight is inside the band, so a flat change is progress and a move either way is neutral, never a setback.", + ), + targetPosition: z + .enum(["below", "inside", "above"]) + .nullable() + .describe( + "Where the 7-day average (else the latest reading) sits against the person's own stored weight target. Null when no target is stored or there is no reading.", + ), + }) + .optional() + .describe( + "Which way a weight change counts as progress, judged against the person's own stored target rather than assuming a fall is good. Render the arrow, 7-day delta and comparison caption from `direction`; do not recompute it. Optional for older cached snapshots; when absent, treat it as `up-bad`.", + ), }), extras: z .object({ diff --git a/src/lib/personal-records/pr-direction.ts b/src/lib/personal-records/pr-direction.ts index deb6568c9..3aade3886 100644 --- a/src/lib/personal-records/pr-direction.ts +++ b/src/lib/personal-records/pr-direction.ts @@ -46,6 +46,7 @@ */ import type { MeasurementType } from "@/generated/prisma/client"; import { PersonalRecordDirection } from "@/generated/prisma/client"; +import { weightTargetPosition } from "@/lib/targets/weight-trend"; /** * v1.34 — the user-goal facts the resolver needs for the goal-dependent @@ -78,11 +79,11 @@ export function resolveWeightGoalDirection( target: { min: number; max: number } | null, latestWeightKg: number | null, ): "lower" | "higher" | null { - if (!target || latestWeightKg === null || !Number.isFinite(latestWeightKg)) { - return null; - } - if (latestWeightKg > target.max) return "lower"; - if (latestWeightKg < target.min) return "higher"; + // Same placement the weight tile's trend colour reads, so a record and an + // arrow can never disagree about which side of the target a person is on. + const position = weightTargetPosition(target, latestWeightKg); + if (position === "above") return "lower"; + if (position === "below") return "higher"; return null; } diff --git a/src/lib/targets/__tests__/weight-trend.test.ts b/src/lib/targets/__tests__/weight-trend.test.ts new file mode 100644 index 000000000..5aaf8e667 --- /dev/null +++ b/src/lib/targets/__tests__/weight-trend.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; + +import { + buildWeightTargetFeature, + resolveWeightTrend, + weightTargetPosition, + weightTrendReferenceKg, +} from "@/lib/targets/weight-trend"; +import { getTrendSentiment } from "@/lib/insights/trend-sentiment"; + +const TARGET = { min: 70, max: 75 }; + +describe("weightTargetPosition", () => { + it("places a reading below, inside or above the stored target", () => { + expect(weightTargetPosition(TARGET, 65)).toBe("below"); + expect(weightTargetPosition(TARGET, 70)).toBe("inside"); + expect(weightTargetPosition(TARGET, 72.5)).toBe("inside"); + expect(weightTargetPosition(TARGET, 75)).toBe("inside"); + expect(weightTargetPosition(TARGET, 80)).toBe("above"); + }); + + it("answers null without a target or without a usable reading", () => { + expect(weightTargetPosition(null, 80)).toBeNull(); + expect(weightTargetPosition(TARGET, null)).toBeNull(); + expect(weightTargetPosition(TARGET, Number.NaN)).toBeNull(); + }); +}); + +describe("resolveWeightTrend", () => { + it("below the target, gaining is progress", () => { + const trend = resolveWeightTrend(TARGET, 62); + expect(trend).toEqual({ direction: "up-good", targetPosition: "below" }); + expect(getTrendSentiment(0.6, trend.direction)).toBe("positive"); + expect(getTrendSentiment(-0.6, trend.direction)).toBe("negative"); + }); + + it("above the target, losing is progress", () => { + const trend = resolveWeightTrend(TARGET, 84); + expect(trend).toEqual({ direction: "up-bad", targetPosition: "above" }); + expect(getTrendSentiment(-0.6, trend.direction)).toBe("positive"); + expect(getTrendSentiment(0.6, trend.direction)).toBe("negative"); + }); + + it("inside the target, holding steady is progress and no move is a setback", () => { + const trend = resolveWeightTrend(TARGET, 72); + expect(trend).toEqual({ direction: "hold", targetPosition: "inside" }); + expect(getTrendSentiment(0, trend.direction)).toBe("positive"); + expect(getTrendSentiment(0.01, trend.direction)).toBe("positive"); + expect(getTrendSentiment(0.6, trend.direction)).toBe("neutral"); + expect(getTrendSentiment(-0.6, trend.direction)).toBe("neutral"); + }); + + it("without a stored target keeps the historical reading: a fall reads as good", () => { + const trend = resolveWeightTrend(null, 84); + expect(trend).toEqual({ direction: "up-bad", targetPosition: null }); + expect(getTrendSentiment(-0.6, trend.direction)).toBe("positive"); + }); + + it("with a target but no reading yet keeps the historical reading too", () => { + expect(resolveWeightTrend(TARGET, null)).toEqual({ + direction: "up-bad", + targetPosition: null, + }); + }); +}); + +describe("weightTrendReferenceKg", () => { + it("prefers the 7-day average so one noisy reading at the band edge does not flip the colour", () => { + expect(weightTrendReferenceKg({ avg7: 71, latest: 69 })).toBe(71); + }); + + it("falls back to the latest reading, then to nothing", () => { + expect(weightTrendReferenceKg({ avg7: null, latest: 69 })).toBe(69); + expect(weightTrendReferenceKg({ avg7: null, latest: null })).toBeNull(); + expect(weightTrendReferenceKg(undefined)).toBeNull(); + }); +}); + +describe("getTrendSentiment — existing directions unchanged", () => { + it("still colours up-good, up-bad and neutral exactly as before", () => { + expect(getTrendSentiment(1, "up-good")).toBe("positive"); + expect(getTrendSentiment(-1, "up-good")).toBe("negative"); + expect(getTrendSentiment(1, "up-bad")).toBe("negative"); + expect(getTrendSentiment(0.01, "up-bad")).toBe("neutral"); + expect(getTrendSentiment(1, "neutral")).toBe("neutral"); + expect(getTrendSentiment(null, "up-good")).toBe("neutral"); + }); + + it("a hold direction with no signal at all stays neutral rather than claiming progress", () => { + expect(getTrendSentiment(null, "hold")).toBe("neutral"); + }); +}); + +describe("buildWeightTargetFeature", () => { + it("names the progress direction for each side of the band", () => { + expect( + buildWeightTargetFeature(TARGET, { avg7: 66, latest: 66 })?.progress, + ).toBe("gaining"); + expect( + buildWeightTargetFeature(TARGET, { avg7: 72, latest: 72 })?.progress, + ).toBe("holding steady"); + expect( + buildWeightTargetFeature(TARGET, { avg7: 80, latest: 80 })?.progress, + ).toBe("losing"); + }); + + it("claims nothing without a stored target or a reading", () => { + expect(buildWeightTargetFeature(null, { avg7: 80, latest: 80 })).toBeNull(); + expect( + buildWeightTargetFeature(TARGET, { avg7: null, latest: null }), + ).toBeNull(); + }); +}); diff --git a/src/lib/targets/weight-trend.ts b/src/lib/targets/weight-trend.ts new file mode 100644 index 000000000..df44dd17b --- /dev/null +++ b/src/lib/targets/weight-trend.ts @@ -0,0 +1,141 @@ +/** + * Which way a weight change counts as progress, judged against the person's + * own stored target rather than against gravity. + * + * The trend arrow, the 7-day delta and the comparison caption on the weight + * tile used to be hard-wired `up-bad`: a falling number read green whatever + * the person was aiming for. Someone below their target who was gaining, which + * is exactly what they set out to do, saw every step coloured as a setback. + * + * The rule here is the whole answer, with no setting of its own: + * + * - below the target band → gaining is progress (`up-good`) + * - above the target band → losing is progress (`up-bad`) + * - inside the target band → holding steady is (`hold`) + * - no stored target → the historical reading (`up-bad`) + * + * The target is the one the person entered on `/targets`, stored as + * `User.thresholdsJson.WEIGHT` and narrowed by `resolveWeightTargetOverride`. + * The height-derived WHO band is deliberately NOT a target here: nobody chose + * it, and a band nobody chose is not a statement about which way they want to + * move. + * + * Resolved on the server and published on the dashboard snapshot + * (`tiles.weightTrend`) so the web tile and the native client colour the same + * reading the same way. The web page's snapshot-disabled fallback calls this + * same function; nothing else re-derives it. + * + * Pure and client-safe: no DB read, no clock. + */ +import type { TrendDirectionSentiment } from "@/lib/insights/trend-sentiment"; + +/** Where a weight sits against the person's own target band. */ +export type WeightTargetPosition = "below" | "inside" | "above"; + +export interface WeightTrendJudgement { + /** The sentiment direction the tile's arrow, delta and caption colour by. */ + direction: Extract; + /** + * Where the reference weight sits against the stored target. Null when no + * target is stored or there is no reading yet — the two cases where the + * historical `up-bad` reading applies. + */ + targetPosition: WeightTargetPosition | null; +} + +/** + * Place a weight against the target band. The band edges count as inside: a + * person sitting exactly on the number they typed has arrived. + */ +export function weightTargetPosition( + target: { min: number; max: number } | null, + weightKg: number | null, +): WeightTargetPosition | null { + if (!target || weightKg === null || !Number.isFinite(weightKg)) return null; + if (weightKg < target.min) return "below"; + if (weightKg > target.max) return "above"; + return "inside"; +} + +/** + * The weight the position is read from: the 7-day average when there is one, + * else the latest reading. A single morning reading near a band edge would + * otherwise flip the arrow colour back and forth from one day to the next, + * while the average moves at the pace the tile's own trend does. + */ +export function weightTrendReferenceKg( + summary: { avg7: number | null; latest: number | null } | null | undefined, +): number | null { + if (!summary) return null; + return summary.avg7 ?? summary.latest ?? null; +} + +export function resolveWeightTrend( + target: { min: number; max: number } | null, + referenceKg: number | null, +): WeightTrendJudgement { + const targetPosition = weightTargetPosition(target, referenceKg); + switch (targetPosition) { + case "below": + return { direction: "up-good", targetPosition }; + case "above": + return { direction: "up-bad", targetPosition }; + case "inside": + return { direction: "hold", targetPosition }; + default: + return { direction: "up-bad", targetPosition: null }; + } +} + +/** + * The same judgement in the shape a narrative model reads: the band, where + * the person is against it, and a plain sentence saying which way is + * progress. Rides `features.weight.target` (briefing, overview analysis, + * Coach) and the weight status snapshot, so an AI note never calls a gain a + * regression when the person is below the target they set. + * + * Null without a stored target or a reading: then no direction is claimed at + * all, which is the honest default for a model (the tile keeps its historical + * colour, the prose makes no promise about which way is better). + */ +export interface WeightTargetFeature { + minKg: number; + maxKg: number; + position: WeightTargetPosition; + progress: "gaining" | "losing" | "holding steady"; + reading: string; +} + +const PROGRESS_BY_POSITION: Record< + WeightTargetPosition, + WeightTargetFeature["progress"] +> = { + below: "gaining", + above: "losing", + inside: "holding steady", +}; + +export function buildWeightTargetFeature( + target: { min: number; max: number } | null, + summary: { avg7: number | null; latest: number | null } | null | undefined, +): WeightTargetFeature | null { + const position = weightTargetPosition( + target, + weightTrendReferenceKg(summary), + ); + if (!target || !position) return null; + const band = `${target.min}–${target.max} kg`; + 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.` + : position === "above" + ? `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, + position, + progress: PROGRESS_BY_POSITION[position], + reading, + }; +}