mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
fix(validation): stop a future timestamp emitting a spurious past-bound error
Review follow-up to the shared entry-instant guard. The past-floor refine re-ran the full plausibility predicate, so a future-dated instant tripped both refines and the 422 carried a nonsensical "too far in the past" message alongside the correct future one. The floor refine now checks the past bound alone; a future value emits only the future message, matching the pre-refactor single-message contract. Also harden the Health-Score BP inputs and de-duplicate the tile-window derivation surfaced in the same review: - the prior-week graded score now keys off the prior-week window's own sufficiency rather than the current window's, so the week-over-week graded delta never pairs a confident current value against a thin-sample prior one; - the BD-Zielbereich 90-day count + effective span derive through one shared `deriveBpWindow90` helper, so the dashboard snapshot and the analytics thick slice can never compute the gate or the label span differently.
This commit is contained in:
1 parent
3e7aa49eb0
commit
3bb130d4a1
8 files changed
+99
-27
No files matched your search
@@ -18,7 +18,7 @@ import {
|
||||
} from "@/lib/analytics/bp-in-target-fast-path";
|
||||
import { computeUserHealthScoreFastPath } from "@/lib/analytics/health-score-fast-path";
|
||||
import { buildHealthScoreBpInputs } from "@/lib/analytics/health-score-inputs";
|
||||
import { computeWindowConfidence } from "@/lib/analytics/window-confidence";
|
||||
import { deriveBpWindow90 } from "@/lib/analytics/window-confidence";
|
||||
import { computeCorrelationHypothesesFastPath } from "@/lib/analytics/correlations-fast-path";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -327,13 +327,14 @@ async function buildAnalyticsResponse(user: AuthedUser) {
|
||||
bpInTargetPctPriorYear = windows.priorYear?.pct ?? null;
|
||||
// v1.17 W1b — count + effective span for the BD-Zielbereich tile's
|
||||
// confidence gate (thin-data → "collecting data"; dynamic span label).
|
||||
bpInTargetCount90 = windows.last90Days?.pairs ?? 0;
|
||||
bpInTargetSpanDays90 = computeWindowConfidence({
|
||||
windowDays: 90,
|
||||
readingCount: bpInTargetCount90,
|
||||
earliestReadingAt: windows.last90EarliestAt,
|
||||
// Shared helper keeps this identical to the dashboard snapshot.
|
||||
const bpWindow = deriveBpWindow90(
|
||||
windows.last90Days,
|
||||
windows.last90EarliestAt,
|
||||
now,
|
||||
}).effectiveSpanDays;
|
||||
);
|
||||
bpInTargetCount90 = bpWindow.count;
|
||||
bpInTargetSpanDays90 = bpWindow.spanDays;
|
||||
}
|
||||
|
||||
// Per-context glucose summaries (canonical mg/dL).
|
||||
|
||||
@@ -85,6 +85,25 @@ describe("buildHealthScoreBpInputs — single Health-Score BP input builder", ()
|
||||
expect(out.bpInTargetPct).toBe(100);
|
||||
});
|
||||
|
||||
it("suppresses the prior-week graded score when the prior-week window is thin and no all-time rescues it", () => {
|
||||
// Current window healthy, prior-week 90-day window below the floor with no
|
||||
// all-time fallback → the prior-week graded score must not grade off the
|
||||
// thin sample, so the week-over-week graded delta stays consistent.
|
||||
const current = env({
|
||||
last90Days: { pct: 65, pairs: 40 },
|
||||
gradedScore: 72,
|
||||
});
|
||||
const priorWeek = env({
|
||||
last90Days: { pct: 90, pairs: 2 },
|
||||
allTime: null,
|
||||
gradedScore: 95,
|
||||
});
|
||||
const out = buildHealthScoreBpInputs(current, priorWeek);
|
||||
expect(out.bpGradedScore).toBe(72);
|
||||
expect(out.bpInTargetPctPriorWeek).toBeNull();
|
||||
expect(out.bpGradedScorePriorWeek).toBeNull();
|
||||
});
|
||||
|
||||
it("collapses every field to null when the current envelope is absent", () => {
|
||||
const out = buildHealthScoreBpInputs(null, null);
|
||||
expect(out).toEqual({
|
||||
|
||||
@@ -92,12 +92,16 @@ export function buildHealthScoreBpInputs(
|
||||
// The graded clinical score is the pillar VALUE; suppress it together with
|
||||
// the rate when the 90-day window is thin AND no all-time rate rescues the
|
||||
// pillar, so the pillar disappears cleanly instead of grading a thin sample.
|
||||
// The prior-week graded score keys off the PRIOR-WEEK window's own presence
|
||||
// (not the current one) so the week-over-week delta never pairs a confident
|
||||
// current value against a thin-sample prior value.
|
||||
const pillarPresent = bpInTargetPct !== null;
|
||||
const priorPillarPresent = bpInTargetPctPriorWeek !== null;
|
||||
return {
|
||||
bpInTargetPct,
|
||||
bpInTargetPctPriorWeek,
|
||||
bpGradedScore: pillarPresent ? current.gradedScore : null,
|
||||
bpGradedScorePriorWeek: pillarPresent
|
||||
bpGradedScorePriorWeek: priorPillarPresent
|
||||
? (priorWeek?.gradedScore ?? null)
|
||||
: null,
|
||||
};
|
||||
|
||||
@@ -55,6 +55,27 @@ export interface WindowConfidence {
|
||||
sufficient: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.17 W1b — derive the BD-Zielbereich tile's confidence inputs (90-day
|
||||
* pair count + effective label span) from a BP envelope's 90-day window.
|
||||
* Shared by the dashboard snapshot and the `/api/analytics` thick slice so
|
||||
* the two surfaces can never compute the tile's gate or span differently.
|
||||
*/
|
||||
export function deriveBpWindow90(
|
||||
last90: { pairs: number } | null,
|
||||
last90EarliestAt: Date | null,
|
||||
now: Date,
|
||||
): { count: number; spanDays: number | null } {
|
||||
const count = last90?.pairs ?? 0;
|
||||
const spanDays = computeWindowConfidence({
|
||||
windowDays: 90,
|
||||
readingCount: count,
|
||||
earliestReadingAt: last90EarliestAt,
|
||||
now,
|
||||
}).effectiveSpanDays;
|
||||
return { count, spanDays };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the effective label span + sufficiency flag for a windowed
|
||||
* metric.
|
||||
|
||||
@@ -142,7 +142,8 @@ describe("buildDashboardSnapshot — envelope shape", () => {
|
||||
computeBpInTargetFastPath.mockResolvedValue({
|
||||
last7Days: { pct: 70 },
|
||||
last30Days: { pct: 80 },
|
||||
last90Days: { pct: 80 },
|
||||
last90Days: { pct: 80, pairs: 20 },
|
||||
last90EarliestAt: new Date("2026-03-01T00:00:00.000Z"),
|
||||
allTime: { pct: 75 },
|
||||
priorMonth: { pct: 60 },
|
||||
priorYear: { pct: 50 },
|
||||
@@ -183,7 +184,8 @@ describe("buildDashboardSnapshot — envelope shape", () => {
|
||||
computeBpInTargetFastPath.mockResolvedValue({
|
||||
last7Days: { pct: 70 },
|
||||
last30Days: { pct: 80 },
|
||||
last90Days: { pct: 80 },
|
||||
last90Days: { pct: 80, pairs: 20 },
|
||||
last90EarliestAt: new Date("2026-03-01T00:00:00.000Z"),
|
||||
allTime: { pct: 75 },
|
||||
priorMonth: { pct: 60 },
|
||||
priorYear: { pct: 50 },
|
||||
@@ -240,7 +242,8 @@ describe("buildDashboardSnapshot — per-type thick-phase gate", () => {
|
||||
computeBpInTargetFastPath.mockResolvedValue({
|
||||
last7Days: { pct: 70 },
|
||||
last30Days: { pct: 80 },
|
||||
last90Days: { pct: 80 },
|
||||
last90Days: { pct: 80, pairs: 20 },
|
||||
last90EarliestAt: new Date("2026-03-01T00:00:00.000Z"),
|
||||
allTime: { pct: 75 },
|
||||
priorMonth: { pct: 60 },
|
||||
priorYear: { pct: 50 },
|
||||
@@ -720,7 +723,8 @@ describe("buildDashboardSnapshot — additive proof", () => {
|
||||
computeBpInTargetFastPath.mockResolvedValue({
|
||||
last7Days: { pct: 70 },
|
||||
last30Days: { pct: 80 },
|
||||
last90Days: { pct: 80 },
|
||||
last90Days: { pct: 80, pairs: 20 },
|
||||
last90EarliestAt: new Date("2026-03-01T00:00:00.000Z"),
|
||||
allTime: { pct: 75 },
|
||||
priorMonth: { pct: 60 },
|
||||
priorYear: { pct: 50 },
|
||||
|
||||
@@ -51,7 +51,7 @@ import {
|
||||
type BpInTargetEnvelope,
|
||||
} from "@/lib/analytics/bp-in-target-fast-path";
|
||||
import { buildHealthScoreBpInputs } from "@/lib/analytics/health-score-inputs";
|
||||
import { computeWindowConfidence } from "@/lib/analytics/window-confidence";
|
||||
import { deriveBpWindow90 } from "@/lib/analytics/window-confidence";
|
||||
import {
|
||||
probeRollupCoverage,
|
||||
type RollupCoverageMap,
|
||||
@@ -502,14 +502,16 @@ async function buildExtras(
|
||||
bpInTargetPctAllTime = windows.allTime?.pct ?? null;
|
||||
bpInTargetPctPriorMonth = windows.priorMonth?.pct ?? null;
|
||||
bpInTargetPctPriorYear = windows.priorYear?.pct ?? null;
|
||||
// v1.17 W1b — count + effective span for the tile's confidence gate.
|
||||
bpInTargetCount90 = windows.last90Days?.pairs ?? 0;
|
||||
bpInTargetSpanDays90 = computeWindowConfidence({
|
||||
windowDays: 90,
|
||||
readingCount: bpInTargetCount90,
|
||||
earliestReadingAt: windows.last90EarliestAt,
|
||||
// v1.17 W1b — count + effective span for the tile's confidence gate,
|
||||
// derived through the shared helper so this surface and `/api/analytics`
|
||||
// can never disagree on the gate or the label span.
|
||||
const bpWindow = deriveBpWindow90(
|
||||
windows.last90Days,
|
||||
windows.last90EarliestAt,
|
||||
now,
|
||||
}).effectiveSpanDays;
|
||||
);
|
||||
bpInTargetCount90 = bpWindow.count;
|
||||
bpInTargetSpanDays90 = bpWindow.spanDays;
|
||||
}
|
||||
|
||||
// Health score — reuses the BP windows captured above plus the
|
||||
|
||||
@@ -87,4 +87,15 @@ describe("validateEntryInstant — Zod integration", () => {
|
||||
it("rejects an instant before 1900", () => {
|
||||
expect(schema.safeParse("1899-12-31T00:00:00.000Z").success).toBe(false);
|
||||
});
|
||||
|
||||
it("emits ONLY the future message for a future instant, not a spurious past-bound error", () => {
|
||||
const future = new Date(Date.now() + 60 * 60 * 1000).toISOString();
|
||||
const r = schema.safeParse(future);
|
||||
expect(r.success).toBe(false);
|
||||
if (!r.success) {
|
||||
const messages = r.error.issues.map((i) => i.message);
|
||||
expect(messages).toContain("Timestamp must not be in the future");
|
||||
expect(messages.some((m) => /past|1900/.test(m))).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -93,14 +93,24 @@ export function validateEntryInstant<T extends z.ZodType<Date>>(
|
||||
? "Timestamp is too far in the past"
|
||||
: "Timestamp must not predate 1900");
|
||||
return schema
|
||||
.refine((d) => d.getTime() <= Date.now() + ENTRY_INSTANT_CLOCK_SKEW_MS, {
|
||||
message: "Timestamp must not be in the future",
|
||||
})
|
||||
.refine(
|
||||
(d) =>
|
||||
d.getTime() <= Date.now() + ENTRY_INSTANT_CLOCK_SKEW_MS,
|
||||
{ message: "Timestamp must not be in the future" },
|
||||
)
|
||||
.refine(
|
||||
(d) =>
|
||||
isPlausibleEntryInstant(d, { ...bounds, now: Date.now() }),
|
||||
// Past-floor check ONLY — a future instant already fails the refine
|
||||
// above, and re-running the full plausibility predicate here would
|
||||
// make a future-dated value emit BOTH messages (the second being a
|
||||
// nonsensical "too far in the past"). Compare against the floor alone.
|
||||
(d) => d.getTime() >= pastFloorMs(bounds),
|
||||
{ message: pastMessage },
|
||||
) as T;
|
||||
}
|
||||
|
||||
/** Resolve the effective past floor (ms) for the given bounds at call time. */
|
||||
function pastFloorMs(bounds: Omit<EntryInstantBounds, "now">): number {
|
||||
if (bounds.maxAgeMs === undefined) return ENTRY_INSTANT_FAR_PAST.getTime();
|
||||
return Math.max(
|
||||
ENTRY_INSTANT_FAR_PAST.getTime(),
|
||||
Date.now() - bounds.maxAgeMs,
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user