mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
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:
1 parent
13ac27c22d
commit
9e647051d5
16 files changed
+215
-37
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -11030,7 +11030,10 @@
|
||||
"PULSE": "맥박",
|
||||
"WEIGHT": "체중",
|
||||
"generic": "생체 지표"
|
||||
}
|
||||
},
|
||||
"steadyAtLeastWeeksOne": "최소 {count}주째 안정적",
|
||||
"steadyAtLeastWeeksFew": "최소 {count}주째 안정적",
|
||||
"steadyAtLeastWeeksOther": "최소 {count}주째 안정적"
|
||||
},
|
||||
"milestone": {
|
||||
"metric": {
|
||||
|
||||
+4
-1
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user