mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(medications): 90-day research-mode acknowledgment staleness gate
This commit is contained in:
1 parent
8502e7b11f
commit
bfd129fc7f
2 files changed
+190
No files matched your search
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
RESEARCH_MODE_ACK_MAX_AGE_DAYS,
|
||||
isAcknowledgmentStale,
|
||||
} from "../research-mode-staleness";
|
||||
|
||||
/**
|
||||
* v1.4.25 W19c-Safety — 90-day staleness gate unit tests.
|
||||
*
|
||||
* The helper is pure (no I/O, no Date.now()) so every test pins both
|
||||
* `acknowledgedAt` and `asOf` explicitly. The threshold is exclusive:
|
||||
* day 90 is still inside the window, day 91 falls out.
|
||||
*/
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
const ASOF = new Date("2026-05-14T12:00:00Z");
|
||||
|
||||
function daysAgo(n: number): Date {
|
||||
return new Date(ASOF.getTime() - n * MS_PER_DAY);
|
||||
}
|
||||
|
||||
describe("RESEARCH_MODE_ACK_MAX_AGE_DAYS", () => {
|
||||
it("defaults to 90 days", () => {
|
||||
expect(RESEARCH_MODE_ACK_MAX_AGE_DAYS).toBe(90);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAcknowledgmentStale — null and invalid inputs", () => {
|
||||
it("null acknowledgment is stale", () => {
|
||||
expect(isAcknowledgmentStale(null, ASOF)).toBe(true);
|
||||
});
|
||||
|
||||
it("non-finite acknowledgedAt is stale", () => {
|
||||
expect(isAcknowledgmentStale(new Date(NaN), ASOF)).toBe(true);
|
||||
});
|
||||
|
||||
it("non-finite asOf is stale (defensive — caller should never pass NaN)", () => {
|
||||
expect(isAcknowledgmentStale(daysAgo(1), new Date(NaN))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAcknowledgmentStale — within window", () => {
|
||||
it("acknowledgment today is fresh", () => {
|
||||
expect(isAcknowledgmentStale(ASOF, ASOF)).toBe(false);
|
||||
});
|
||||
|
||||
it("acknowledgment 1 day ago is fresh", () => {
|
||||
expect(isAcknowledgmentStale(daysAgo(1), ASOF)).toBe(false);
|
||||
});
|
||||
|
||||
it("acknowledgment 30 days ago is fresh", () => {
|
||||
expect(isAcknowledgmentStale(daysAgo(30), ASOF)).toBe(false);
|
||||
});
|
||||
|
||||
it("acknowledgment 89 days ago is fresh", () => {
|
||||
expect(isAcknowledgmentStale(daysAgo(89), ASOF)).toBe(false);
|
||||
});
|
||||
|
||||
it("acknowledgment exactly 90 days ago is still fresh (threshold is exclusive)", () => {
|
||||
expect(isAcknowledgmentStale(daysAgo(90), ASOF)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAcknowledgmentStale — outside window", () => {
|
||||
it("acknowledgment 91 days ago is stale", () => {
|
||||
expect(isAcknowledgmentStale(daysAgo(91), ASOF)).toBe(true);
|
||||
});
|
||||
|
||||
it("acknowledgment 180 days ago is stale", () => {
|
||||
expect(isAcknowledgmentStale(daysAgo(180), ASOF)).toBe(true);
|
||||
});
|
||||
|
||||
it("acknowledgment 365 days ago is stale", () => {
|
||||
expect(isAcknowledgmentStale(daysAgo(365), ASOF)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAcknowledgmentStale — custom maxAgeDays", () => {
|
||||
it("respects a 30-day override", () => {
|
||||
expect(isAcknowledgmentStale(daysAgo(29), ASOF, 30)).toBe(false);
|
||||
expect(isAcknowledgmentStale(daysAgo(30), ASOF, 30)).toBe(false);
|
||||
expect(isAcknowledgmentStale(daysAgo(31), ASOF, 30)).toBe(true);
|
||||
});
|
||||
|
||||
it("respects a 365-day override", () => {
|
||||
expect(isAcknowledgmentStale(daysAgo(180), ASOF, 365)).toBe(false);
|
||||
expect(isAcknowledgmentStale(daysAgo(366), ASOF, 365)).toBe(true);
|
||||
});
|
||||
|
||||
it("treats zero or negative maxAgeDays as stale (defensive)", () => {
|
||||
expect(isAcknowledgmentStale(daysAgo(1), ASOF, 0)).toBe(true);
|
||||
expect(isAcknowledgmentStale(daysAgo(1), ASOF, -10)).toBe(true);
|
||||
});
|
||||
|
||||
it("treats non-finite maxAgeDays as stale (defensive)", () => {
|
||||
expect(isAcknowledgmentStale(daysAgo(1), ASOF, NaN)).toBe(true);
|
||||
expect(isAcknowledgmentStale(daysAgo(1), ASOF, Infinity)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAcknowledgmentStale — clock-skew defence", () => {
|
||||
it("acknowledgment in the future relative to asOf is treated as fresh", () => {
|
||||
const future = new Date(ASOF.getTime() + 5 * MS_PER_DAY);
|
||||
expect(isAcknowledgmentStale(future, ASOF)).toBe(false);
|
||||
});
|
||||
|
||||
it("acknowledgment one millisecond ahead is treated as fresh", () => {
|
||||
const slightlyAhead = new Date(ASOF.getTime() + 1);
|
||||
expect(isAcknowledgmentStale(slightlyAhead, ASOF)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* v1.4.25 W19c-Safety — Research-mode acknowledgment staleness helper.
|
||||
*
|
||||
* W19c-Frontend ships a version-mismatch re-prompt that fires whenever
|
||||
* `acknowledgedVersion !== currentDisclaimerVersion`. The version
|
||||
* constant only ratchets when Marc bumps `RESEARCH_MODE_DISCLAIMER_VERSION`
|
||||
* in `glp1-pk.ts` (copy change, new drug, new EMA EPAR version) — so
|
||||
* a user who acknowledged the current disclaimer 89 days ago and 360
|
||||
* days ago looks identical to that gate.
|
||||
*
|
||||
* This helper layers a second gate: an acknowledgment older than 90
|
||||
* days is "stale" regardless of whether the version still matches.
|
||||
* The chart can then re-prompt the user on a calendar boundary
|
||||
* (annual-ish if the version never ratchets, sooner otherwise) so
|
||||
* consent stays freshly given.
|
||||
*
|
||||
* Why a sibling module rather than an addition to `glp1-pk.ts`:
|
||||
* `glp1-pk.ts` is the W19c-Backend pure-math surface; it owns the PK
|
||||
* compute and the disclaimer-version constant, but adding a date-math
|
||||
* helper to it would dilute its responsibility. This file is the
|
||||
* thin staleness gate; importers compose it with `glp1-pk.ts` as
|
||||
* needed.
|
||||
*
|
||||
* The helper is pure: it accepts the acknowledgment timestamp + the
|
||||
* `asOf` date (caller-supplied, never `Date.now()` inside the
|
||||
* function) and returns a boolean. No I/O, no Prisma, no fetch.
|
||||
*
|
||||
* Note: W19c-Frontend has shipped without this gate wired into the
|
||||
* `DrugLevelChart` placeholder. Wiring it into the chart's gating
|
||||
* decision tree is a v1.4.26 cleanup item — see the W19c-Safety
|
||||
* phase report for the deferral rationale.
|
||||
*/
|
||||
|
||||
/** Default staleness threshold — 90 days from acknowledgment. */
|
||||
export const RESEARCH_MODE_ACK_MAX_AGE_DAYS = 90;
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Returns true when the supplied acknowledgment is older than
|
||||
* `maxAgeDays` (default 90) relative to `asOf`.
|
||||
*
|
||||
* Behaviour:
|
||||
* - `acknowledgedAt === null` => `true` (no acknowledgment is, by
|
||||
* definition, stale).
|
||||
* - `acknowledgedAt` is a `Date` that's exactly `maxAgeDays` old =>
|
||||
* `false` (the threshold is exclusive; day 90 is still inside the
|
||||
* window, day 91 falls out).
|
||||
* - `acknowledgedAt` in the FUTURE relative to `asOf` => `false`
|
||||
* (treat as freshly given; defensive against clock skew or a
|
||||
* stale `asOf` from caller).
|
||||
* - Non-positive `maxAgeDays` => `true` whenever `acknowledgedAt`
|
||||
* is non-null (callers should not pass zero or negative, but the
|
||||
* helper degrades safely toward "force re-acknowledge").
|
||||
*
|
||||
* @param acknowledgedAt — the `User.researchModeAcknowledgedAt`
|
||||
* timestamp persisted by W19c-Backend. `null` when the user has
|
||||
* never acknowledged the current disclaimer.
|
||||
* @param asOf — the caller-supplied "now". Pass `new Date()` from
|
||||
* call-sites; tests pin this for determinism.
|
||||
* @param maxAgeDays — staleness threshold in days. Defaults to
|
||||
* {@link RESEARCH_MODE_ACK_MAX_AGE_DAYS}.
|
||||
*/
|
||||
export function isAcknowledgmentStale(
|
||||
acknowledgedAt: Date | null,
|
||||
asOf: Date,
|
||||
maxAgeDays: number = RESEARCH_MODE_ACK_MAX_AGE_DAYS,
|
||||
): boolean {
|
||||
if (acknowledgedAt === null) return true;
|
||||
if (!Number.isFinite(acknowledgedAt.getTime())) return true;
|
||||
if (!Number.isFinite(asOf.getTime())) return true;
|
||||
// Future acknowledgment (clock skew) — treat as fresh.
|
||||
if (acknowledgedAt.getTime() > asOf.getTime()) return false;
|
||||
if (!Number.isFinite(maxAgeDays) || maxAgeDays <= 0) return true;
|
||||
const ageMs = asOf.getTime() - acknowledgedAt.getTime();
|
||||
const thresholdMs = maxAgeDays * MS_PER_DAY;
|
||||
return ageMs > thresholdMs;
|
||||
}
|
||||
Reference in new issue
Block a user