fix(dashboard): tell a steady score only when its band held, and say at least at the read's edge

The steady line compared the newest stored score with the number on
screen but not with its band, version or composition, so a score whose
band moved overnight at the same number was still called steady. A run
that reached back past the 120 days read was also told as if it started
there.

The newest stored day now has to match the ring in value, band and,
where the digest carries them, algorithm version and composition. A run
the read cannot see the start of is marked as open (steadyAtLeast on the
digest score) and the hero says "at least" in seven locales.
This commit is contained in:
Marc-André Bombeck committed 2026-10-05 08:34:27 +02:00
1 parent 13ac27c22d
commit 9e647051d5
16 files changed
+215 -37

No files matched your search

+6 -1
View File
@@ -72483,12 +72483,17 @@ components:
steadyWeeks:
description: Whole weeks the score has held where it is (same band, within two points, computed the same way), read off
the stored daily scores. Null when it has not held for two weeks, when the stored record does not
reach today, or when the score moved. Shown in place of a delta, never beside one.
reach today, or when the score on screen differs from the newest stored day in value, band,
algorithm version or composition. Shown in place of a delta, never beside one.
anyOf:
- type: integer
exclusiveMinimum: 0
maximum: 9007199254740991
- type: "null"
steadyAtLeast:
description: "True when the run reaches back past everything the read covered (120 days): `steadyWeeks` is then a lower
bound, said as \"at least\", and the run's start is unknown. Absent on older cached digests."
type: boolean
required:
- value
- band
+4 -1
View File
@@ -11030,7 +11030,10 @@
"PULSE": "Puls",
"WEIGHT": "Gewicht",
"generic": "Ein Vitalwert"
}
},
"steadyAtLeastWeeksOne": "Seit mindestens {count} Woche stabil",
"steadyAtLeastWeeksFew": "Seit mindestens {count} Wochen stabil",
"steadyAtLeastWeeksOther": "Seit mindestens {count} Wochen stabil"
},
"milestone": {
"metric": {
+4 -1
View File
@@ -11030,7 +11030,10 @@
"PULSE": "Pulse",
"WEIGHT": "Weight",
"generic": "A vital"
}
},
"steadyAtLeastWeeksOne": "Steady for at least {count} week",
"steadyAtLeastWeeksFew": "Steady for at least {count} weeks",
"steadyAtLeastWeeksOther": "Steady for at least {count} weeks"
},
"milestone": {
"metric": {
+4 -1
View File
@@ -11030,7 +11030,10 @@
"PULSE": "Pulso",
"WEIGHT": "Peso",
"generic": "Un signo vital"
}
},
"steadyAtLeastWeeksOne": "Estable desde hace al menos {count} semana",
"steadyAtLeastWeeksFew": "Estable desde hace al menos {count} semanas",
"steadyAtLeastWeeksOther": "Estable desde hace al menos {count} semanas"
},
"milestone": {
"metric": {
+4 -1
View File
@@ -11030,7 +11030,10 @@
"PULSE": "Pouls",
"WEIGHT": "Poids",
"generic": "Un signe vital"
}
},
"steadyAtLeastWeeksOne": "Stable depuis au moins {count} semaine",
"steadyAtLeastWeeksFew": "Stable depuis au moins {count} semaines",
"steadyAtLeastWeeksOther": "Stable depuis au moins {count} semaines"
},
"milestone": {
"metric": {
+4 -1
View File
@@ -11030,7 +11030,10 @@
"PULSE": "Polso",
"WEIGHT": "Peso",
"generic": "Un parametro vitale"
}
},
"steadyAtLeastWeeksOne": "Stabile da almeno {count} settimana",
"steadyAtLeastWeeksFew": "Stabile da almeno {count} settimane",
"steadyAtLeastWeeksOther": "Stabile da almeno {count} settimane"
},
"milestone": {
"metric": {
+4 -1
View File
@@ -11030,7 +11030,10 @@
"PULSE": "맥박",
"WEIGHT": "체중",
"generic": "생체 지표"
}
},
"steadyAtLeastWeeksOne": "최소 {count}주째 안정적",
"steadyAtLeastWeeksFew": "최소 {count}주째 안정적",
"steadyAtLeastWeeksOther": "최소 {count}주째 안정적"
},
"milestone": {
"metric": {
+4 -1
View File
@@ -11030,7 +11030,10 @@
"PULSE": "Tętno",
"WEIGHT": "Waga",
"generic": "Parametr życiowy"
}
},
"steadyAtLeastWeeksOne": "Stabilnie od co najmniej {count} tygodnia",
"steadyAtLeastWeeksFew": "Stabilnie od co najmniej {count} tygodni",
"steadyAtLeastWeeksOther": "Stabilnie od co najmniej {count} tygodni"
},
"milestone": {
"metric": {
@@ -669,6 +669,13 @@ describe("<TodayHero> steady line", () => {
expect(html).not.toContain('data-slot="today-hero-score-steady"');
});
it("says at least when the run reaches past what was read", () => {
const html = render(
<TodayHero digest={steady({ steadyWeeks: 17, steadyAtLeast: true })} />,
);
expect(visibleText(html)).toContain("Steady for at least 17 weeks");
});
it("uses the German plural", () => {
const html = render(<TodayHero digest={steady({})} />, "de");
expect(visibleText(html)).toContain("Seit 4 Wochen stabil");
+7 -3
View File
@@ -179,9 +179,13 @@ export function TodayHero({
const steadyWeeks = digest.score?.steadyWeeks ?? null;
const steadyLine =
!deltaShown && steadyWeeks !== null
? t(pluralKey("daily.today.steadyWeeks", steadyWeeks, locale), {
count: steadyWeeks,
})
? t(
// A run that reaches past what was read has no known start.
digest.score?.steadyAtLeast
? pluralKey("daily.today.steadyAtLeastWeeks", steadyWeeks, locale)
: pluralKey("daily.today.steadyWeeks", steadyWeeks, locale),
{ count: steadyWeeks },
)
: null;
// v1.38 — what the ring's number rests on, when that is less than the
+11 -1
View File
@@ -115,6 +115,7 @@ describe("buildDailyDigest — composition", () => {
band: "good",
delta: 3,
steadyWeeks: null,
steadyAtLeast: false,
});
expect(d.topSignal?.headline).toBe("Blood pressure is holding steady");
expect(d.briefingLead).toBe(
@@ -1564,7 +1565,16 @@ describe("buildDailyDigest — Today overview", () => {
});
it("carries the steady run on the score", () => {
const d = buildDailyDigest(input({ scoreSteadyWeeks: 5 }), t);
const d = buildDailyDigest(
input({ scoreSteady: { weeks: 5, atLeast: false } }),
t,
);
expect(d.score?.steadyWeeks).toBe(5);
expect(d.score?.steadyAtLeast).toBe(false);
const open = buildDailyDigest(
input({ scoreSteady: { weeks: 17, atLeast: true } }),
t,
);
expect(open.score?.steadyAtLeast).toBe(true);
});
});
+53 -2
View File
@@ -1,6 +1,10 @@
import { describe, it, expect } from "vitest";
import { steadyWeeks, type StoredScoreDay } from "@/lib/daily/score-steady";
import {
STEADY_READ_DAYS,
steadyRun,
type StoredScoreDay,
} from "@/lib/daily/score-steady";
import { addDays } from "@/lib/cycle/day-math";
const TODAY = "2026-10-04";
@@ -21,7 +25,16 @@ function run(
}));
}
describe("steadyWeeks", () => {
/** The run's weeks against a ring showing `value` in the green band. */
function steadyWeeks(
rows: StoredScoreDay[],
today: string,
value: number,
): number | null {
return steadyRun(rows, today, { value, band: "GREEN" })?.weeks ?? null;
}
describe("steadyRun", () => {
it("counts whole weeks of a held score", () => {
expect(steadyWeeks(run(29), TODAY, 94)).toBe(4);
});
@@ -76,4 +89,42 @@ describe("steadyWeeks", () => {
expect(steadyWeeks(run(29).reverse(), TODAY, 94)).toBe(4);
expect(steadyWeeks([], TODAY, 94)).toBeNull();
});
it("says nothing when the ring's band is not the record's", () => {
// Same number, but the worst pillar pulled the live band down overnight:
// the score did not hold where it is.
expect(steadyRun(run(29), TODAY, { value: 94, band: "yellow" })).toBeNull();
// Case is the enum's, not a difference.
expect(steadyRun(run(29), TODAY, { value: 94, band: "green" })?.weeks).toBe(
4,
);
});
it("says nothing when the ring is computed another way than the record", () => {
expect(
steadyRun(run(29), TODAY, { value: 94, band: "GREEN", scoreVersion: 5 }),
).toBeNull();
expect(
steadyRun(run(29), TODAY, {
value: 94,
band: "GREEN",
composition: ["BLOOD_PRESSURE"],
}),
).toBeNull();
});
it("says at least, not since, when the run reaches past what was read", () => {
const all = run(STEADY_READ_DAYS + 1);
const out = steadyRun(all, TODAY, { value: 94, band: "GREEN" });
expect(out).toEqual({
weeks: Math.floor(STEADY_READ_DAYS / 7),
atLeast: true,
});
// A run that ended inside the read has a start and says so.
const ended = all.map((r, i) => (i >= 40 ? { ...r, composite: 80 } : r));
expect(steadyRun(ended, TODAY, { value: 94, band: "GREEN" })).toEqual({
weeks: 5,
atLeast: false,
});
});
});
+13 -3
View File
@@ -56,6 +56,7 @@ import {
} from "@/lib/daily/coach-checkin-intents";
import { DOSE_WINDOW_DEFAULTS } from "@/lib/medications/scheduling/dose-window-defaults";
import type { Locale } from "@/lib/i18n/config";
import type { SteadyRun } from "@/lib/daily/score-steady";
import {
buildTodayOverview,
type TodayCycle,
@@ -138,6 +139,11 @@ export interface DailyDigestScore {
* stay valid.
*/
steadyWeeks?: number | null;
/**
* True when the run reaches back past everything the read covered, so
* `steadyWeeks` is a lower bound ("at least"), not the run's start.
*/
steadyAtLeast?: boolean;
}
/** A broken integration, deterministically derived from `IntegrationStatus`. */
@@ -404,8 +410,8 @@ export interface DailyDigestInput {
vitals?: readonly TodayVital[];
/** Today's place in the cycle (cycle module). */
cycle?: TodayCycle | null;
/** Whole weeks the score has held; see `DailyDigestScore.steadyWeeks`. */
scoreSteadyWeeks?: number | null;
/** How long the score has held; see `DailyDigestScore.steadyWeeks`. */
scoreSteady?: SteadyRun | null;
}
export interface DailyDigest {
@@ -1250,7 +1256,11 @@ export function buildDailyDigest(
);
const score: DailyDigestScore | null = input.score
? { ...input.score, steadyWeeks: input.scoreSteadyWeeks ?? null }
? {
...input.score,
steadyWeeks: input.scoreSteady?.weeks ?? null,
steadyAtLeast: input.scoreSteady?.atLeast ?? false,
}
: null;
return {
+12 -3
View File
@@ -88,7 +88,7 @@ import type {
} from "@/lib/format-locale";
import {
STEADY_READ_DAYS,
steadyWeeks,
steadyRun,
type StoredScoreDay,
} from "@/lib/daily/score-steady";
import type {
@@ -951,8 +951,17 @@ export async function loadDailyDigest(
toTodayVital(v, units, resolvedLocale, t),
),
cycle,
scoreSteadyWeeks: score
? steadyWeeks(scoreDays ?? [], todayLocalDate, score.value)
scoreSteady: score
? steadyRun(scoreDays ?? [], todayLocalDate, {
value: score.value,
band: score.band,
...(score.scoreVersion !== undefined
? { scoreVersion: score.scoreVersion }
: {}),
...(score.composition !== undefined
? { composition: score.composition }
: {}),
})
: null,
},
t,
+71 -16
View File
@@ -10,11 +10,12 @@
*
* A day belongs to the run while it was computed the same way (same algorithm
* version, same composition), sat in the same band, and stayed within
* {@link STEADY_TOLERANCE} points of the newest value. A gap in the record
* {@link STEADY_TOLERANCE} points of the newest value, and the newest stored
* day must match the score on screen the same way. A gap in the record
* longer than {@link STEADY_MAX_GAP_DAYS} ends the run: an absence is not
* evidence of steadiness.
*/
import { dayDiff } from "@/lib/cycle/day-math";
import { addDays, dayDiff } from "@/lib/cycle/day-math";
/** Points either side of the newest value that still count as "the same". */
export const STEADY_TOLERANCE = 2;
@@ -40,38 +41,92 @@ function sameComposition(a: readonly string[], b: readonly string[]): boolean {
return a.length === b.length && a.every((id, i) => id === b[i]);
}
/** The score the ring shows, as the run is compared against it. */
export interface ShownScore {
value: number;
band: string;
/** Absent on an older snapshot; then only value and band are compared. */
scoreVersion?: number;
composition?: readonly string[];
}
/** How long the score has held. */
export interface SteadyRun {
/** Whole weeks the run covers, counted over stored days that were read. */
weeks: number;
/**
* True when the run reaches the start of what was read
* ({@link STEADY_READ_DAYS}): the score has held at least `weeks`, and when
* it started is not known. Said as "at least", never as a start.
*/
atLeast: boolean;
}
function sameBand(a: string, b: string): boolean {
return a.toLowerCase() === b.toLowerCase();
}
/**
* Whole weeks the score has held, or null when it has not held for at least
* How long the score has held, or null when it has not held for at least
* {@link STEADY_MIN_WEEKS}. `rows` may come in any order; `todayKey` is the
* reader's local day, so a record that stopped updating days ago does not
* claim a run that reaches today. The newest stored day must also agree with
* the number on screen: the ring is computed live, the record nightly, and a
* run that ended overnight is not "steady" next to a number that has moved.
* the score on screen in value, band and, where the screen says, algorithm
* version and composition: the ring is computed live, the record nightly,
* and a run that ended overnight is not "steady" next to a number, or a band,
* that has moved.
*/
export function steadyWeeks(
export function steadyRun(
rows: readonly StoredScoreDay[],
todayKey: string,
/** The number the ring shows; a run that does not end there is not told. */
shownValue: number,
): number | null {
/** The score the ring shows; a run that does not end there is not told. */
shown: ShownScore,
): SteadyRun | null {
if (rows.length === 0) return null;
const sorted = [...rows].sort((a, b) =>
a.dayKey < b.dayKey ? 1 : a.dayKey > b.dayKey ? -1 : 0,
);
const newest = sorted[0];
if (dayDiff(todayKey, newest.dayKey) > STEADY_MAX_GAP_DAYS) return null;
if (Math.abs(newest.composite - shownValue) > STEADY_TOLERANCE) return null;
if (Math.abs(newest.composite - shown.value) > STEADY_TOLERANCE) return null;
if (!sameBand(newest.band, shown.band)) return null;
if (
shown.scoreVersion !== undefined &&
newest.scoreVersion !== shown.scoreVersion
) {
return null;
}
if (
shown.composition !== undefined &&
!sameComposition(newest.composition, shown.composition)
) {
return null;
}
let oldest = newest;
let broke = false;
for (const row of sorted.slice(1)) {
if (dayDiff(oldest.dayKey, row.dayKey) > STEADY_MAX_GAP_DAYS) break;
if (row.scoreVersion !== newest.scoreVersion) break;
if (!sameComposition(row.composition, newest.composition)) break;
if (row.band !== newest.band) break;
if (Math.abs(row.composite - newest.composite) > STEADY_TOLERANCE) break;
if (
dayDiff(oldest.dayKey, row.dayKey) > STEADY_MAX_GAP_DAYS ||
row.scoreVersion !== newest.scoreVersion ||
!sameComposition(row.composition, newest.composition) ||
!sameBand(row.band, newest.band) ||
Math.abs(row.composite - newest.composite) > STEADY_TOLERANCE
) {
broke = true;
break;
}
oldest = row;
}
const weeks = Math.floor(dayDiff(todayKey, oldest.dayKey) / 7);
return weeks >= STEADY_MIN_WEEKS ? weeks : null;
if (weeks < STEADY_MIN_WEEKS) return null;
// Nothing ended the run inside what was read, and what was read ends
// within a bridgeable gap of the read's own start: the run may go back
// further than anything here can tell.
const atLeast =
!broke &&
dayDiff(oldest.dayKey, addDays(todayKey, -STEADY_READ_DAYS)) <=
STEADY_MAX_GAP_DAYS;
return { weeks, atLeast };
}
+7 -1
View File
@@ -133,7 +133,13 @@ export const dailyDigestResponse = z
.nullable()
.optional()
.describe(
"Whole weeks the score has held where it is (same band, within two points, computed the same way), read off the stored daily scores. Null when it has not held for two weeks, when the stored record does not reach today, or when the score moved. Shown in place of a delta, never beside one.",
"Whole weeks the score has held where it is (same band, within two points, computed the same way), read off the stored daily scores. Null when it has not held for two weeks, when the stored record does not reach today, or when the score on screen differs from the newest stored day in value, band, algorithm version or composition. Shown in place of a delta, never beside one.",
),
steadyAtLeast: z
.boolean()
.optional()
.describe(
'True when the run reaches back past everything the read covered (120 days): `steadyWeeks` is then a lower bound, said as "at least", and the run\'s start is unknown. Absent on older cached digests.',
),
})
.nullable()