feat(weight): judge the weight trend against the stored target

The weight tile's arrow, 7-day delta and comparison caption were wired
as "up is bad", so a person below their own target who was gaining saw
every step toward it coloured as a setback. Movement is now judged
against the target they entered: below the band a gain is progress,
above it a loss is, and inside it holding steady is, with a move either
way shown neutral rather than orange. Without a stored target the tile
reads exactly as before; the height-derived band is not treated as a
target because nobody chose it.

The direction is resolved once on the server and published on the
dashboard snapshot as tiles.weightTrend, so the web tile and the native
client colour the same reading the same way. The personal-record
direction reads the same placement helper. The feature payload behind
the briefing, the overview analysis and the Coach now carries the
target band and which way is progress, so a narrative no longer calls a
gain a regression when gaining is the goal. The insights vitals grid
drops weight from its fixed "up is bad" set.

Refs #1006
This commit is contained in:
Marc-André Bombeck committed 2026-09-24 09:22:50 +02:00
1 parent 0c3195fd41
commit 1273a76a63
14 files changed
+577 -24

No files matched your search

+29
View File
@@ -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
@@ -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
* `<TrendCard>` 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) ?? "<absent>");
}
}
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}"]);
});
});
+30 -14
View File
@@ -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")}
@@ -68,6 +68,31 @@ describe("<TrendCard> 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(
<TrendCard
{...baseProps}
slope30={STABLE}
trend7Delta={0}
directionSentiment="hold"
/>,
);
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(
<TrendCard {...baseProps} slope30={slope} directionSentiment="hold" />,
);
expect(html).not.toContain("text-warning");
expect(html).not.toContain("text-success");
}
});
it("paints up-good metric ↓ as orange", () => {
const html = render(
<TrendCard
@@ -64,12 +64,16 @@ import type { DerivedProvenance } from "@/lib/insights/derived/types";
// moved to `use-dashboard-derived.ts` so the page can own the one shared batch
// and feed it to both the wellness strip and this grid. Imported above.
/** Up-is-bad for the vitals where a rise is unfavourable. */
/**
* Up-is-bad for the vitals where a rise is unfavourable. Weight is not one of
* them: which way it should move depends on the person's own target (#1006,
* `src/lib/targets/weight-trend.ts`), and this grid has no target in hand, so
* it stays neutral rather than assuming a fall is good.
*/
const UP_BAD_VITALS = new Set([
"RESTING_HEART_RATE",
"BODY_TEMPERATURE",
"BLOOD_GLUCOSE",
"WEIGHT",
]);
/** Up-is-good for the vitals where a rise is favourable. */
const UP_GOOD_VITALS = new Set(["HEART_RATE_VARIABILITY", "OXYGEN_SATURATION"]);
@@ -306,6 +306,59 @@ describe("buildDashboardSnapshot — envelope shape", () => {
});
});
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]]));
+23 -1
View File
@@ -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,
@@ -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([
+18
View File
@@ -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
+14 -2
View File
@@ -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 =
@@ -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({
+6 -5
View File
@@ -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;
}
@@ -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();
});
});
+141
View File
@@ -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<TrendDirectionSentiment, "up-good" | "up-bad" | "hold">;
/**
* 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,
};
}