diff --git a/src/lib/analytics/__tests__/health-score.test.ts b/src/lib/analytics/__tests__/health-score.test.ts index c4da7181e..d858bac86 100644 --- a/src/lib/analytics/__tests__/health-score.test.ts +++ b/src/lib/analytics/__tests__/health-score.test.ts @@ -280,6 +280,20 @@ describe("computeHealthScore — determinism", () => { const b = computeHealthScore(input); expect(b).toEqual(a); }); + + // v1.4.25 Fix-G — the legacy fallback used to be `new Date()`, which + // made `asOf` drift by a millisecond between consecutive calls and + // turned the determinism guard above into a flake on loaded runners. + // The synthesised window-end now derives from input dates, not wall + // clock, so the entire result is byte-identical across calls. + it("derives a deterministic asOf for present components when attribution is omitted", () => { + const a = computeHealthScore(input); + const b = computeHealthScore(input); + expect(b.components.bp.asOf).toBe(a.components.bp.asOf); + expect(b.components.weight.asOf).toBe(a.components.weight.asOf); + expect(b.components.mood.asOf).toBe(a.components.mood.asOf); + expect(b.components.compliance.asOf).toBe(a.components.compliance.asOf); + }); }); describe("computeHealthScore — delta vs previous week", () => { diff --git a/src/lib/analytics/health-score.ts b/src/lib/analytics/health-score.ts index c55ba06ba..4fccdeb64 100644 --- a/src/lib/analytics/health-score.ts +++ b/src/lib/analytics/health-score.ts @@ -283,7 +283,7 @@ export function computeHealthScore( const complianceValue = complianceRate(input.medicationCompliance30); const attribution = input.attribution ?? {}; - const windowEndAt = attribution.windowEndAt ?? new Date().toISOString(); + const windowEndAt = attribution.windowEndAt ?? deriveWindowEndAt(input); const components = redistribute( { bp: bpValue, @@ -410,6 +410,32 @@ function redistribute( }; } +/** + * v1.4.25 Fix-G — when the caller doesn't supply `attribution.windowEndAt` + * we synthesise it deterministically from the input data so + * `computeHealthScore` stays pure (same input → same output). The route + * always supplies its own `windowEndAt`; this fallback exists so unit + * tests and ad-hoc callers don't pull `new Date()` into the result. + * + * Strategy: take the most recent date across the weight and mood series. + * If neither has any entries (e.g. compliance-only input), fall back to + * the Unix epoch so the value is stable and obviously synthetic — the + * UI never renders this string when attribution is provided. + */ +function deriveWindowEndAt(input: HealthScoreInput): string { + let latest = -Infinity; + for (const p of input.weightSeriesLast30d) { + const t = new Date(p.date).getTime(); + if (Number.isFinite(t) && t > latest) latest = t; + } + for (const p of input.moodEntriesLast30d) { + const t = new Date(p.date).getTime(); + if (Number.isFinite(t) && t > latest) latest = t; + } + if (latest === -Infinity) return new Date(0).toISOString(); + return new Date(latest).toISOString(); +} + /** * The `weightTargetKg` input is a single number; we expand it to a * narrow ±2 kg band so the alignment helper has a "target zone" to