diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index f2f4f730d..bd82bec73 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -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). diff --git a/src/lib/analytics/__tests__/health-score-inputs.test.ts b/src/lib/analytics/__tests__/health-score-inputs.test.ts index b4ae183d1..214b6d4c5 100644 --- a/src/lib/analytics/__tests__/health-score-inputs.test.ts +++ b/src/lib/analytics/__tests__/health-score-inputs.test.ts @@ -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({ diff --git a/src/lib/analytics/health-score-inputs.ts b/src/lib/analytics/health-score-inputs.ts index 2c26382c2..d4919f94e 100644 --- a/src/lib/analytics/health-score-inputs.ts +++ b/src/lib/analytics/health-score-inputs.ts @@ -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, }; diff --git a/src/lib/analytics/window-confidence.ts b/src/lib/analytics/window-confidence.ts index f5a4a4219..d49947158 100644 --- a/src/lib/analytics/window-confidence.ts +++ b/src/lib/analytics/window-confidence.ts @@ -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. diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts index 423d95f02..d621e45a8 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -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 }, diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index 343a6968c..b0ecb259e 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -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 diff --git a/src/lib/validations/__tests__/entry-instant.test.ts b/src/lib/validations/__tests__/entry-instant.test.ts index 9d0132d94..fdc7fd03d 100644 --- a/src/lib/validations/__tests__/entry-instant.test.ts +++ b/src/lib/validations/__tests__/entry-instant.test.ts @@ -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); + } + }); }); diff --git a/src/lib/validations/entry-instant.ts b/src/lib/validations/entry-instant.ts index aa641aee9..8af95c19e 100644 --- a/src/lib/validations/entry-instant.ts +++ b/src/lib/validations/entry-instant.ts @@ -93,14 +93,24 @@ export function validateEntryInstant>( ? "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): number { + if (bounds.maxAgeMs === undefined) return ENTRY_INSTANT_FAR_PAST.getTime(); + return Math.max( + ENTRY_INSTANT_FAR_PAST.getTime(), + Date.now() - bounds.maxAgeMs, + ); +}