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:
Marc-André Bombeck committed 2026-06-14 13:59:56 +02:00
1 parent 3e7aa49eb0
commit 3bb130d4a1
8 files changed
+99 -27

No files matched your search

+8 -7
View File
@@ -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({
+5 -1
View File
@@ -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,
};
+21
View File
@@ -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.
+8 -4
View File
@@ -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 },
+10 -8
View File
@@ -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);
}
});
});
+17 -7
View File
@@ -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,
);
}