mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user