diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 3b509b723..d2b4000fa 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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 diff --git a/messages/de.json b/messages/de.json index 6884f4c63..3091393ce 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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": { diff --git a/messages/en.json b/messages/en.json index cb85ff501..1339fe560 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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": { diff --git a/messages/es.json b/messages/es.json index 81ede2572..6f58f002e 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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": { diff --git a/messages/fr.json b/messages/fr.json index 44ed03a77..4d9b4945b 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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": { diff --git a/messages/it.json b/messages/it.json index ab994a606..4551242b1 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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": { diff --git a/messages/ko.json b/messages/ko.json index 451dcdd77..dd9af50b9 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -11030,7 +11030,10 @@ "PULSE": "맥박", "WEIGHT": "체중", "generic": "생체 지표" - } + }, + "steadyAtLeastWeeksOne": "최소 {count}주째 안정적", + "steadyAtLeastWeeksFew": "최소 {count}주째 안정적", + "steadyAtLeastWeeksOther": "최소 {count}주째 안정적" }, "milestone": { "metric": { diff --git a/messages/pl.json b/messages/pl.json index f9d4baca9..b304b7b5c 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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": { diff --git a/src/components/daily/__tests__/today-hero.test.tsx b/src/components/daily/__tests__/today-hero.test.tsx index 648a838cd..6e6810c40 100644 --- a/src/components/daily/__tests__/today-hero.test.tsx +++ b/src/components/daily/__tests__/today-hero.test.tsx @@ -669,6 +669,13 @@ describe(" 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( + , + ); + expect(visibleText(html)).toContain("Steady for at least 17 weeks"); + }); + it("uses the German plural", () => { const html = render(, "de"); expect(visibleText(html)).toContain("Seit 4 Wochen stabil"); diff --git a/src/components/daily/today-hero.tsx b/src/components/daily/today-hero.tsx index bdd649bb3..ae474ff29 100644 --- a/src/components/daily/today-hero.tsx +++ b/src/components/daily/today-hero.tsx @@ -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 diff --git a/src/lib/daily/__tests__/digest.test.ts b/src/lib/daily/__tests__/digest.test.ts index c45af38ce..89ac1f62b 100644 --- a/src/lib/daily/__tests__/digest.test.ts +++ b/src/lib/daily/__tests__/digest.test.ts @@ -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); }); }); diff --git a/src/lib/daily/__tests__/score-steady.test.ts b/src/lib/daily/__tests__/score-steady.test.ts index fa501c361..40f17caeb 100644 --- a/src/lib/daily/__tests__/score-steady.test.ts +++ b/src/lib/daily/__tests__/score-steady.test.ts @@ -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, + }); + }); }); diff --git a/src/lib/daily/digest.ts b/src/lib/daily/digest.ts index 0379c23a7..59c46ffe8 100644 --- a/src/lib/daily/digest.ts +++ b/src/lib/daily/digest.ts @@ -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 { diff --git a/src/lib/daily/load-digest.ts b/src/lib/daily/load-digest.ts index de34bc90e..df1943241 100644 --- a/src/lib/daily/load-digest.ts +++ b/src/lib/daily/load-digest.ts @@ -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, diff --git a/src/lib/daily/score-steady.ts b/src/lib/daily/score-steady.ts index 613e07be7..31d404ad0 100644 --- a/src/lib/daily/score-steady.ts +++ b/src/lib/daily/score-steady.ts @@ -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 }; } diff --git a/src/lib/openapi/routes/daily.ts b/src/lib/openapi/routes/daily.ts index 4f7b35338..df379a80f 100644 --- a/src/lib/openapi/routes/daily.ts +++ b/src/lib/openapi/routes/daily.ts @@ -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()