fix(health-score): derive asOf deterministically from input dates

The fallback for missing `attribution.windowEndAt` was `new Date()`,
which made consecutive `computeHealthScore()` calls drift by a
millisecond on loaded CI runners and turned the determinism guard into
a flake. The fallback now picks the most recent input date across the
weight and mood series (Unix epoch when neither has entries) so the
result is byte-identical across calls. Production routes already
supply their own `windowEndAt` and stay unaffected.

Adds a focused test asserting per-component `asOf` stability across
back-to-back invocations.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 18:57:38 +02:00
1 parent 7f133a1aa7
commit 4feeafab96
2 files changed
+41 -1

No files matched your search

@@ -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", () => {
+27 -1
View File
@@ -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