diff --git a/messages/de.json b/messages/de.json index 94442b3f6..019dcdcde 100644 --- a/messages/de.json +++ b/messages/de.json @@ -326,6 +326,7 @@ "stepsShort": "Schritte", "vo2MaxShort": "VO₂ max", "bpInTargetShort": "BD-Ziel", + "bpInTargetWindow90": "BD-Ziel · 90 T", "medications": "Medikamente", "quickAddMeasurement": "Messung erfassen", "quickAddMood": "Stimmung erfassen", diff --git a/messages/en.json b/messages/en.json index febabae4b..cf984502e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -326,6 +326,7 @@ "stepsShort": "Steps", "vo2MaxShort": "VO₂ max", "bpInTargetShort": "BP in Target", + "bpInTargetWindow90": "BP in Target · 90d", "medications": "Medications", "quickAddMeasurement": "Log measurement", "quickAddMood": "Log mood", diff --git a/messages/es.json b/messages/es.json index bad70d032..7d69659f0 100644 --- a/messages/es.json +++ b/messages/es.json @@ -326,6 +326,7 @@ "stepsShort": "Pasos", "vo2MaxShort": "VO₂ máx", "bpInTargetShort": "PA-objetivo", + "bpInTargetWindow90": "PA-objetivo · 90 d", "medications": "Medicamentos", "quickAddMeasurement": "Registrar medición", "quickAddMood": "Registrar estado de ánimo", diff --git a/messages/fr.json b/messages/fr.json index 5efaddac7..7b2c9bd70 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -326,6 +326,7 @@ "stepsShort": "Pas", "vo2MaxShort": "VO₂ max", "bpInTargetShort": "TA-cible", + "bpInTargetWindow90": "TA-cible · 90 j", "medications": "Médicaments", "quickAddMeasurement": "Saisir une mesure", "quickAddMood": "Saisir une humeur", diff --git a/messages/it.json b/messages/it.json index 7c064c30f..91f9469e4 100644 --- a/messages/it.json +++ b/messages/it.json @@ -326,6 +326,7 @@ "stepsShort": "Passi", "vo2MaxShort": "VO₂ max", "bpInTargetShort": "PA-target", + "bpInTargetWindow90": "PA-target · 90 gg", "medications": "Farmaci", "quickAddMeasurement": "Registra misurazione", "quickAddMood": "Registra umore", diff --git a/messages/pl.json b/messages/pl.json index bb5cab1b5..f1f976d49 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -326,6 +326,7 @@ "stepsShort": "Kroki", "vo2MaxShort": "VO₂ max", "bpInTargetShort": "RR-cel", + "bpInTargetWindow90": "RR-cel · 90 d", "medications": "Leki", "quickAddMeasurement": "Zarejestruj pomiar", "quickAddMood": "Zarejestruj nastrój", diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index e706750fc..84df0a8ea 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -242,6 +242,8 @@ async function buildAnalyticsResponse(user: AuthedUser) { let bpInTargetPct: number | null = null; let bpInTargetPct7d: number | null = null; let bpInTargetPct30d: number | null = null; + // v1.17 W1d — the canonical 90-day window for the headline / score / coach. + let bpInTargetPct90d: number | null = null; let bpInTargetPctAllTime: number | null = null; /** * v1.4.22 W5 reconcile (Code-H2) — period-aligned prior-window @@ -262,10 +264,11 @@ async function buildAnalyticsResponse(user: AuthedUser) { * the resulting `last30Days.pct`. */ let bpInTargetPctPriorWeek: number | null = null; - // All-time prior-week BP pct — feeds the Health-Score delta so its BP - // pillar compares the same window on both ends (the tile headline keeps - // its 30-day scope; the score reads all-time per its documented contract). - let windowsPriorWeekAllTime: number | null = null; + // v1.17 W1d — prior-week 90-day BP pct. Feeds the Health-Score delta so + // its BP pillar compares the same 90-day window on both ends, identical + // to the headline scope. (Pre-v1.17 the score read all-time; W1d + // unifies headline + score + coach on 90 days.) + let windowsPriorWeekForScore: number | null = null; // v1.15.12 A1 — graded clinical-proximity BP score (recency-weighted // representative reading) for the Health-Score BP pillar value, plus // the prior-week run for the week-over-week delta. @@ -315,20 +318,24 @@ async function buildAnalyticsResponse(user: AuthedUser) { userTz, }), ]); - bpInTargetPct = windows.last30Days?.pct ?? null; + // v1.17 W1d — the BD-Zielbereich headline standardises on the + // trailing-90-day window (labelled "· 90 T" in the tile). All-time + // remains carried below for the BP detail page's long view only. + bpInTargetPct = windows.last90Days?.pct ?? null; bpInTargetPct7d = windows.last7Days?.pct ?? null; bpInTargetPct30d = windows.last30Days?.pct ?? null; + bpInTargetPct90d = windows.last90Days?.pct ?? null; bpInTargetPctAllTime = windows.allTime?.pct ?? null; bpInTargetPctPriorMonth = windows.priorMonth?.pct ?? null; bpInTargetPctPriorYear = windows.priorYear?.pct ?? null; - bpInTargetPctPriorWeek = windowsPriorWeek.last30Days?.pct ?? null; + bpInTargetPctPriorWeek = windowsPriorWeek.last90Days?.pct ?? null; bpGradedScore = windows.gradedScore; bpGradedScorePriorWeek = windowsPriorWeek.gradedScore; - // The Health-Score BP pillar reads the all-time window (see the - // `bpInTargetPctForScore` derivation below). Capture the prior-week - // run's all-time pct too so the week-over-week delta compares the + // v1.17 W1d — the Health-Score BP pillar reads the same 90-day window + // as the headline (see `bpInTargetPctForScore` below). Capture the + // prior-week run's 90-day pct so the week-over-week delta compares the // same window on both ends. - windowsPriorWeekAllTime = windowsPriorWeek.allTime?.pct ?? null; + windowsPriorWeekForScore = windowsPriorWeek.last90Days?.pct ?? null; } // Per-context glucose summaries (canonical mg/dL). @@ -396,9 +403,14 @@ async function buildAnalyticsResponse(user: AuthedUser) { // all-time rate, so route the all-time window through; fall back to the // 30-day window only when the all-time window is itself null (it never // is when 30-day has data), so a brand-new account is unaffected. - const bpInTargetPctForScore = bpInTargetPctAllTime ?? bpInTargetPct; + // v1.17 W1d — the Health-Score BP pillar reads the SAME 90-day window as + // the BD-Zielbereich tile headline, so the dashboard ring and the + // insights card grade the BP pillar off one number. Fall back to the + // all-time rate only when the 90-day window is itself null so a + // sparse-but-historical account still surfaces a BP pillar. + const bpInTargetPctForScore = bpInTargetPct90d ?? bpInTargetPctAllTime; const bpInTargetPctPriorWeekForScore = - windowsPriorWeekAllTime ?? bpInTargetPctPriorWeek; + windowsPriorWeekForScore ?? bpInTargetPctPriorWeek; const healthScore = await computeUserHealthScoreFastPath({ userId: user.id, bpInTargetPct: bpInTargetPctForScore, diff --git a/src/app/page.tsx b/src/app/page.tsx index ba7c5126e..8eded4b07 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1098,7 +1098,11 @@ export default function DashboardPage() { node: ( { expect(result.allTime).toBeNull(); }); + /** + * v1.17 W1d — the BD-Zielbereich headline, the Health-Score BP pillar + * and the coach grounding number standardise on a **90-day** window. + * The helper must surface a `last90Days` window distinct from both the + * 30-day and the all-time aggregate so the route can route the headline + * through it while leaving all-time available for the detail page. + */ + it("returns a last90Days window distinct from 30-day and all-time", () => { + // 10 pairs inside 30d (50 % in target), 10 pairs at 45-60 days old + // (all OUT of target), 20 pairs older than 90 days (all OUT). + const sys: Array> = []; + const dia: Array> = []; + // 0-30d: 5 IN, 5 OUT. + for (let i = 0; i < 10; i++) { + const at = new Date( + NOW_19.getTime() - (i + 0.5) * 24 * 60 * 60 * 1000, + ).toISOString(); + const inTarget = i % 2 === 0; + sys.push(reading(at, inTarget ? 122 : 145)); + dia.push(reading(at, inTarget ? 75 : 95)); + } + // 45-60d (inside 90d, outside 30d): 10 OUT. + for (let i = 0; i < 10; i++) { + const at = new Date( + NOW_19.getTime() - (45 + i) * 24 * 60 * 60 * 1000, + ).toISOString(); + sys.push(reading(at, 160)); + dia.push(reading(at, 100)); + } + // >90d: 20 OUT. + for (let i = 0; i < 20; i++) { + const at = new Date( + NOW_19.getTime() - (100 + i) * 24 * 60 * 60 * 1000, + ).toISOString(); + sys.push(reading(at, 160)); + dia.push(reading(at, 100)); + } + + const result = computeBpInTargetWindows(sys, dia, TARGETS_UNDER_65, NOW_19); + + // 30d = 5 / 10 = 50 %. + expect(result.last30Days).toEqual({ pct: 50, pairs: 10 }); + // 90d = 5 / 20 = 25 % (10 in-target-eligible + 10 OUT from 45-60d). + expect(result.last90Days).not.toBeNull(); + expect(result.last90Days!.pairs).toBe(20); + expect(result.last90Days!.pct).toBe(Math.round((5 / 20) * 100)); + // all-time = 5 / 40 ≈ 13 %. + expect(result.allTime!.pairs).toBe(40); + // The three windows must be genuinely different numbers. + expect(result.last90Days!.pct).not.toBe(result.last30Days!.pct); + expect(result.last90Days!.pct).not.toBe(result.allTime!.pct); + }); + + it("returns null last90Days when both series are empty", () => { + const result = computeBpInTargetWindows([], [], TARGETS_UNDER_65, NOW_19); + expect(result.last90Days).toBeNull(); + }); + it("regression: the live tenant's 30-day fixture mirrors the live tenant's prod headline bug", () => { // Reuse the maintainer's production fixture (10 pairs, 5 in target = 50 %). // When the input is JUST those 10 pairs, allTime equals 30d == 50 % diff --git a/src/lib/analytics/bp-in-target-fast-path.ts b/src/lib/analytics/bp-in-target-fast-path.ts index a901cf2da..3d43eab47 100644 --- a/src/lib/analytics/bp-in-target-fast-path.ts +++ b/src/lib/analytics/bp-in-target-fast-path.ts @@ -76,6 +76,12 @@ import type { BpTargets } from "./bp-targets"; export interface BpInTargetEnvelope { last7Days: { pct: number; pairs: number } | null; last30Days: { pct: number; pairs: number } | null; + /** + * v1.17 W1d — trailing-90-day window. Canonical window for the + * BD-Zielbereich headline, the Health-Score BP pillar and the coach + * grounding number. See `computeBpInTargetWindows` for the rationale. + */ + last90Days: { pct: number; pairs: number } | null; allTime: { pct: number; pairs: number } | null; priorMonth: { pct: number; pairs: number } | null; priorYear: { pct: number; pairs: number } | null; @@ -274,12 +280,15 @@ async function computeFromRollups( const sevenDaysAgo = new Date(now.getTime() - 7 * DAY_MS); const thirtyDaysAgo = new Date(now.getTime() - 30 * DAY_MS); + const ninetyDaysAgo = new Date(now.getTime() - 90 * DAY_MS); const sixtyDaysAgo = new Date(now.getTime() - 60 * DAY_MS); const threeSixtyFiveDaysAgo = new Date(now.getTime() - 365 * DAY_MS); const threeNinetyFiveDaysAgo = new Date(now.getTime() - 395 * DAY_MS); const last7 = bucketWindow(pairsByDay, sevenDaysAgo, now, targets); const last30 = bucketWindow(pairsByDay, thirtyDaysAgo, now, targets); + // v1.17 W1d — canonical headline / score / coach window. + const last90 = bucketWindow(pairsByDay, ninetyDaysAgo, now, targets); // All-time on the rollup path is bounded to the 395-day read window. // Documented above — same trade-off the per-event path makes when the // user's history extends beyond 365 days. @@ -339,6 +348,7 @@ async function computeFromRollups( return { last7Days: last7, last30Days: last30, + last90Days: last90, allTime, priorMonth, priorYear, @@ -453,6 +463,7 @@ async function computeFromLive( return { last7Days: windows.last7Days, last30Days: windows.last30Days, + last90Days: windows.last90Days, allTime: windows.allTime, priorMonth: windows.priorMonth, priorYear: windows.priorYear, diff --git a/src/lib/analytics/bp-in-target.ts b/src/lib/analytics/bp-in-target.ts index 12e0ceaed..8ddaed1e0 100644 --- a/src/lib/analytics/bp-in-target.ts +++ b/src/lib/analytics/bp-in-target.ts @@ -237,6 +237,14 @@ export function computeBpInTargetWindows( ): { last7Days: { pct: number; pairs: number } | null; last30Days: { pct: number; pairs: number } | null; + /** + * v1.17 W1d — the trailing-90-day window. This is the canonical window + * the BD-Zielbereich headline, the Health-Score BP pillar and the coach + * grounding number all standardise on (a 90-day arc balances recency + * against enough pairs to be stable). The all-time aggregate stays + * available below but is reserved for the BP detail page's long view. + */ + last90Days: { pct: number; pairs: number } | null; allTime: { pct: number; pairs: number } | null; /** * v1.4.22 W5 reconcile (Code-H2) — period-aligned 30-day window @@ -254,6 +262,7 @@ export function computeBpInTargetWindows( } { const sevenDaysAgoMs = now.getTime() - 7 * DAY_MS; const thirtyDaysAgoMs = now.getTime() - 30 * DAY_MS; + const ninetyDaysAgoMs = now.getTime() - 90 * DAY_MS; const sixtyDaysAgoMs = now.getTime() - 60 * DAY_MS; const oneYearMinus30DaysAgoMs = now.getTime() - 365 * DAY_MS; const oneYearAgoMs = now.getTime() - 395 * DAY_MS; @@ -270,6 +279,12 @@ export function computeBpInTargetWindows( const diaLast30 = diaSeries.filter( (r) => r.measuredAt.getTime() >= thirtyDaysAgoMs, ); + const sysLast90 = sysSeries.filter( + (r) => r.measuredAt.getTime() >= ninetyDaysAgoMs, + ); + const diaLast90 = diaSeries.filter( + (r) => r.measuredAt.getTime() >= ninetyDaysAgoMs, + ); // Period-aligned prior windows. Same 30-day arc, shifted by 30 / 365 // days respectively. Bounds are inclusive on the lower end and @@ -299,6 +314,8 @@ export function computeBpInTargetWindows( return { last7Days: computeBpInTargetPct(sysLast7, diaLast7, targets), last30Days: computeBpInTargetPct(sysLast30, diaLast30, targets), + // v1.17 W1d — canonical headline / score / coach window. + last90Days: computeBpInTargetPct(sysLast90, diaLast90, targets), // v1.4.19 A1 — independent aggregate over EVERY paired reading. The // analytics route now routes the dashboard tile's headline through // this so it stops mirroring `last30Days`. diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts index 74149aba8..564a6ee0f 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -142,6 +142,7 @@ describe("buildDashboardSnapshot — envelope shape", () => { computeBpInTargetFastPath.mockResolvedValue({ last7Days: { pct: 70 }, last30Days: { pct: 80 }, + last90Days: { pct: 80 }, allTime: { pct: 75 }, priorMonth: { pct: 60 }, priorYear: { pct: 50 }, @@ -182,6 +183,7 @@ describe("buildDashboardSnapshot — envelope shape", () => { computeBpInTargetFastPath.mockResolvedValue({ last7Days: { pct: 70 }, last30Days: { pct: 80 }, + last90Days: { pct: 80 }, allTime: { pct: 75 }, priorMonth: { pct: 60 }, priorYear: { pct: 50 }, @@ -238,6 +240,7 @@ describe("buildDashboardSnapshot — per-type thick-phase gate", () => { computeBpInTargetFastPath.mockResolvedValue({ last7Days: { pct: 70 }, last30Days: { pct: 80 }, + last90Days: { pct: 80 }, allTime: { pct: 75 }, priorMonth: { pct: 60 }, priorYear: { pct: 50 }, @@ -279,6 +282,7 @@ describe("buildDashboardSnapshot — per-type thick-phase gate", () => { computeBpInTargetFastPath.mockResolvedValue({ last7Days: null, last30Days: null, + last90Days: null, allTime: null, priorMonth: null, priorYear: null, @@ -364,6 +368,8 @@ describe("buildDashboardSnapshot — healthScore (warm phase only)", () => { computeBpInTargetFastPath.mockResolvedValue({ last7Days: { pct: 70 }, last30Days: { pct: 80 }, + // v1.17 W1d — the BP pillar reads this 90-day window, NOT last30Days. + last90Days: { pct: 77 }, allTime: { pct: 75 }, priorMonth: { pct: 60 }, priorYear: { pct: 50 }, @@ -392,7 +398,10 @@ describe("buildDashboardSnapshot — healthScore (warm phase only)", () => { coverage: unknown; }; expect(arg.userId).toBe("user-1"); - expect(arg.bpInTargetPct).toBe(80); + // v1.17 W1d — the BP pillar reads the 90-day window, identical to the + // analytics route, so dashboard ring and insights card score off one + // number. Previously this was last30Days (80). + expect(arg.bpInTargetPct).toBe(77); expect(arg.bpGradedScore).toBe(88); expect(arg.heightCm).toBe(180); expect(arg.coverage).toBe(coverageMap); @@ -404,6 +413,7 @@ describe("buildDashboardSnapshot — healthScore (warm phase only)", () => { computeBpInTargetFastPath.mockResolvedValue({ last7Days: null, last30Days: null, + last90Days: null, allTime: null, priorMonth: null, priorYear: null, @@ -705,6 +715,7 @@ describe("buildDashboardSnapshot — additive proof", () => { computeBpInTargetFastPath.mockResolvedValue({ last7Days: { pct: 70 }, last30Days: { pct: 80 }, + last90Days: { pct: 80 }, allTime: { pct: 75 }, priorMonth: { pct: 60 }, priorYear: { pct: 50 }, diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index 376dcb7e5..3650de8c0 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -440,6 +440,8 @@ async function buildExtras( let bpInTargetPct: number | null = null; let bpInTargetPct7d: number | null = null; let bpInTargetPct30d: number | null = null; + // v1.17 W1d — canonical 90-day headline / score window. + let bpInTargetPct90d: number | null = null; let bpInTargetPctAllTime: number | null = null; let bpInTargetPctPriorMonth: number | null = null; let bpInTargetPctPriorYear: number | null = null; @@ -454,9 +456,15 @@ async function buildExtras( coverage, userTz, }); - bpInTargetPct = windows.last30Days?.pct ?? null; + // v1.17 W1d — the headline standardises on the trailing-90-day + // window (labelled "· 90 T" in the tile), identical to the analytics + // route, so the dashboard tile and the insights surface never narrate + // two windows for the same metric. All-time stays carried for the + // detail page's long view only. + bpInTargetPct = windows.last90Days?.pct ?? null; bpInTargetPct7d = windows.last7Days?.pct ?? null; bpInTargetPct30d = windows.last30Days?.pct ?? null; + bpInTargetPct90d = windows.last90Days?.pct ?? null; bpInTargetPctAllTime = windows.allTime?.pct ?? null; bpInTargetPctPriorMonth = windows.priorMonth?.pct ?? null; bpInTargetPctPriorYear = windows.priorYear?.pct ?? null; @@ -466,10 +474,17 @@ async function buildExtras( // Health score — reuses the BP windows captured above plus the // already-probed coverage map (no second probe). Score + band + // delta only; the component breakdown stays off this wire. + // + // v1.17 W1b/W1d — the BP pillar reads the SAME 90-day window as the + // analytics route, so the dashboard ring and the insights card compute + // the score from identical inputs (closing the dashboard-vs-insights + // score divergence). Fall back to all-time only when the 90-day window + // is null so a sparse-but-historical account keeps its BP pillar. + const bpInTargetPctForScore = bpInTargetPct90d ?? bpInTargetPctAllTime; const scoreResult = await time("healthScore", () => computeUserHealthScoreFastPath({ userId: user.id, - bpInTargetPct, + bpInTargetPct: bpInTargetPctForScore, bpGradedScore, heightCm: user.heightCm, now,