From 3b1314d1b0eda83f730ee49b8bc4d469923097d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 14 Aug 2026 05:19:05 +0200 Subject: [PATCH] Enforce the reminder nag cap, compare the disclaimer version, drop the unread analytics columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The daily Coach sweep now charges each ignored day against surfaceCount and auto-dismisses at three — the cap the schema comment promised since v1.22 but nothing enforced. - The welcome gate compares the stored disclaimer acknowledgment version against the current one, so revised disclaimer text re-prompts; /api/auth/me carries the version for the client. - slope90 and anomalyCount leave the analytics wire: slope90 had no reader anywhere (computed on every request, in SQL twice), and the slim path could never compute anomalyCount (it always reported 0). anomalyCount stays as an in-process field on summarize() for the insights feature builder, which is its only real consumer. --- e2e/utils/mock-dashboard-snapshot.ts | 1 - src/app/api/analytics/__tests__/route.test.ts | 1 - src/app/api/analytics/route.ts | 16 ++++--- src/app/api/auth/me/route.ts | 4 ++ src/app/api/insights/cards/route.ts | 2 +- src/app/api/insights/comprehensive/route.ts | 2 +- src/components/dashboard/range-display.tsx | 3 +- .../__tests__/device-score-surfaces.test.tsx | 1 - .../insights-edit-mode-manager.test.tsx | 2 +- .../insights-tab-strip-memo.test.tsx | 1 - .../__tests__/insights-tab-strip.test.tsx | 1 - .../__tests__/metric-stat-strip.test.tsx | 1 - .../__tests__/glucose-clinical-panel.test.tsx | 1 - .../onboarding/welcome-carousel.tsx | 24 ++++++---- src/hooks/use-auth.ts | 7 +++ .../__tests__/merge-slim-thick.test.ts | 1 - .../__tests__/summaries-slice.test.ts | 24 +++++----- src/lib/analytics/summaries-slice.ts | 32 +++----------- src/lib/analytics/trends.ts | 13 ++++-- src/lib/dashboard/__tests__/snapshot.test.ts | 1 - .../comprehensive-aggregator.test.ts | 9 ---- .../__tests__/metric-availability.test.ts | 1 - src/lib/insights/comprehensive-aggregator.ts | 21 +-------- src/lib/insights/features.ts | 4 +- .../__tests__/coach-reminder-sweep.test.ts | 40 +++++++++++++++++ src/lib/jobs/coach-reminder-sweep.ts | 44 +++++++++++++++++++ src/lib/jobs/reminder/register-status.ts | 2 + .../onboarding/__tests__/disclaimer.test.ts | 44 +++++++++++++++++++ src/lib/onboarding/disclaimer.ts | 15 +++++++ 29 files changed, 214 insertions(+), 104 deletions(-) create mode 100644 src/lib/onboarding/__tests__/disclaimer.test.ts diff --git a/e2e/utils/mock-dashboard-snapshot.ts b/e2e/utils/mock-dashboard-snapshot.ts index a01771183..400c47e41 100644 --- a/e2e/utils/mock-dashboard-snapshot.ts +++ b/e2e/utils/mock-dashboard-snapshot.ts @@ -116,7 +116,6 @@ function summary(partial: Partial): DataSummary { avg30: null, slope7: null, slope30: null, - slope90: null, anomalyCount: 0, avg30LastMonth: null, avg30LastYear: null, diff --git a/src/app/api/analytics/__tests__/route.test.ts b/src/app/api/analytics/__tests__/route.test.ts index 44a4903f7..18c2f8552 100644 --- a/src/app/api/analytics/__tests__/route.test.ts +++ b/src/app/api/analytics/__tests__/route.test.ts @@ -329,7 +329,6 @@ describe("GET /api/analytics", () => { r2_7: 0.4, slope30: -0.02, r2_30: 0.3, - slope90: -0.01, r2_90: 0.2, }, ] as never) diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index 08fa120b3..ea430bc31 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -233,19 +233,21 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) { // identical to the previous fan-out output for every field the // dashboard actually consumes: // - `count / latest / min / max / mean` from DAY buckets - // - `avg7 / avg30 / slope7 / slope30 / slope90` from the narrow query + // - `avg7 / avg30 / slope7 / slope30` from the narrow query // - `avg30LastMonth` from the same narrow query (added in this // release; previously only the live walk produced it) // - `avg30LastYear` populated for any type whose WMY tier carries // the year-ago window // - `lastSeenByType` from the `DISTINCT ON (type)` latest read // - // The two fields the slim path leaves at default values are - // `anomalyCount` (always 0) — the insights pipeline consumes it from - // its own `comprehensive-aggregator` narrow query, never from this - // route — and `avg30LastYear` on types with no WMY coverage, which - // matches the pre-fix behaviour for tenants whose year-ago window - // happened to fall outside the 425-day floor. + // `anomalyCount` and `slope90` left this wire in v1.37.19: the slim + // path could never compute the former (it always reported 0) and no + // client component read the latter — the insights pipeline computes + // both from its own `summarize()` / comprehensive-aggregator reads. + // The one field the slim path leaves at a default value is + // `avg30LastYear` on types with no WMY coverage, which matches the + // pre-fix behaviour for tenants whose year-ago window happened to + // fall outside the 425-day floor. const slim = await computeSummariesSlice(user.id); const results = slim.summaries; const lastSeenByType = slim.lastSeenByType; diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index 1c2a7520d..425bfa33a 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -110,6 +110,10 @@ export const GET = apiHandler(async () => { // when never acknowledged; the onboarding welcome gate reads this to // decide whether to require the acknowledgment before "Get started". disclaimerAcknowledgedAt: user.disclaimerAcknowledgedAt, + // The version that acknowledgment was given for. The welcome gate + // compares it against the current DISCLAIMER_VERSION so a revised + // disclaimer re-prompts instead of riding an old acknowledgment. + disclaimerAcknowledgedVersion: user.disclaimerAcknowledgedVersion, // v1.18.6 — resumable module-tour progress. Null when the user has // not started the tour; otherwise the resume point the launcher // seeds its index from. Fail-soft parse: a corrupt blob degrades to diff --git a/src/app/api/insights/cards/route.ts b/src/app/api/insights/cards/route.ts index 7f1734587..23dc79ae7 100644 --- a/src/app/api/insights/cards/route.ts +++ b/src/app/api/insights/cards/route.ts @@ -253,7 +253,7 @@ export const GET = apiHandler(async () => { bpPctInTarget, weightSlope30: summaries.WEIGHT?.slope30?.slope ?? null, pulseAvg30: summaries.PULSE?.avg30 ?? null, - pulseAnomalyCount: summaries.PULSE?.anomalyCount, + pulseAnomalyCount: summaries.PULSE?.anomalyCount ?? 0, medications: medicationCompliance, }); diff --git a/src/app/api/insights/comprehensive/route.ts b/src/app/api/insights/comprehensive/route.ts index e0919bb23..a3cf6e902 100644 --- a/src/app/api/insights/comprehensive/route.ts +++ b/src/app/api/insights/comprehensive/route.ts @@ -535,7 +535,7 @@ export async function buildComprehensiveResponse(user: AuthedUser) { bpPctInTarget, weightSlope30: summaries.WEIGHT?.slope30?.slope ?? null, pulseAvg30: summaries.PULSE?.avg30 ?? null, - pulseAnomalyCount: summaries.PULSE?.anomalyCount, + pulseAnomalyCount: summaries.PULSE?.anomalyCount ?? 0, medications: medCompliance.map((m) => ({ name: m.name, compliance7: m.compliance7, diff --git a/src/components/dashboard/range-display.tsx b/src/components/dashboard/range-display.tsx index 4c16758f5..66256bac1 100644 --- a/src/components/dashboard/range-display.tsx +++ b/src/components/dashboard/range-display.tsx @@ -21,7 +21,7 @@ export interface RangeDisplayConfig { * mean / median / avg7 / avg30 / avg30LastMonth / avg30LastYear) divides * by 60; the slope tuples scale their `slope` (per-day rate) by the same * factor so the trend arrow stays consistent; count / direction / - * confidence / anomalyCount are unit-free and pass through. + * confidence are unit-free and pass through. */ export function toHoursSummary(s: DataSummary): DataSummary { const h = (v: number | null | undefined): number | null => @@ -43,7 +43,6 @@ export function toHoursSummary(s: DataSummary): DataSummary { avg30LastYear: h(s.avg30LastYear), slope7: scaleSlope(s.slope7), slope30: scaleSlope(s.slope30), - slope90: scaleSlope(s.slope90), }; } diff --git a/src/components/insights/__tests__/device-score-surfaces.test.tsx b/src/components/insights/__tests__/device-score-surfaces.test.tsx index 4aa8994ed..b97208e08 100644 --- a/src/components/insights/__tests__/device-score-surfaces.test.tsx +++ b/src/components/insights/__tests__/device-score-surfaces.test.tsx @@ -64,7 +64,6 @@ function summary(over: Partial): DataSummary { avg30: null, slope7: null, slope30: null, - slope90: null, anomalyCount: 0, ...over, }; diff --git a/src/components/insights/__tests__/insights-edit-mode-manager.test.tsx b/src/components/insights/__tests__/insights-edit-mode-manager.test.tsx index f9b21c002..d39d4fad5 100644 --- a/src/components/insights/__tests__/insights-edit-mode-manager.test.tsx +++ b/src/components/insights/__tests__/insights-edit-mode-manager.test.tsx @@ -52,7 +52,7 @@ describe(" — detail-page manager retired (v1.15.20)", () => expect(html).toContain( 'href="/settings/layout/insights#insights-pill-order"', ); - expect(html).toContain("Manage pills & detail pages in Settings"); + expect(html).toContain("Manage chips & detail pages in Settings"); }); it("still renders the section rows with their eye toggles", () => { diff --git a/src/components/insights/__tests__/insights-tab-strip-memo.test.tsx b/src/components/insights/__tests__/insights-tab-strip-memo.test.tsx index 6345c1c0c..84fe96a32 100644 --- a/src/components/insights/__tests__/insights-tab-strip-memo.test.tsx +++ b/src/components/insights/__tests__/insights-tab-strip-memo.test.tsx @@ -25,7 +25,6 @@ function fakeSummary(count: number): DataSummary { avg30: null, slope7: null, slope30: null, - slope90: null, anomalyCount: 0, }; } diff --git a/src/components/insights/__tests__/insights-tab-strip.test.tsx b/src/components/insights/__tests__/insights-tab-strip.test.tsx index d8e103e8a..3aedbc6f9 100644 --- a/src/components/insights/__tests__/insights-tab-strip.test.tsx +++ b/src/components/insights/__tests__/insights-tab-strip.test.tsx @@ -36,7 +36,6 @@ function fakeSummary(count: number): DataSummary { avg30: null, slope7: null, slope30: null, - slope90: null, anomalyCount: 0, }; } diff --git a/src/components/insights/__tests__/metric-stat-strip.test.tsx b/src/components/insights/__tests__/metric-stat-strip.test.tsx index c85632ecc..051a28c65 100644 --- a/src/components/insights/__tests__/metric-stat-strip.test.tsx +++ b/src/components/insights/__tests__/metric-stat-strip.test.tsx @@ -36,7 +36,6 @@ const populated: DataSummary = { avg30: 70, slope7: null, slope30: null, - slope90: null, anomalyCount: 0, avg30LastMonth: null, avg30LastYear: null, diff --git a/src/components/insights/glucose/__tests__/glucose-clinical-panel.test.tsx b/src/components/insights/glucose/__tests__/glucose-clinical-panel.test.tsx index 51b5b8359..cd3a60b1e 100644 --- a/src/components/insights/glucose/__tests__/glucose-clinical-panel.test.tsx +++ b/src/components/insights/glucose/__tests__/glucose-clinical-panel.test.tsx @@ -20,7 +20,6 @@ function summary(count: number): DataSummary { avg30: null, slope7: null, slope30: null, - slope90: null, anomalyCount: 0, }; } diff --git a/src/components/onboarding/welcome-carousel.tsx b/src/components/onboarding/welcome-carousel.tsx index 4d218a300..12c3d3ab3 100644 --- a/src/components/onboarding/welcome-carousel.tsx +++ b/src/components/onboarding/welcome-carousel.tsx @@ -24,7 +24,10 @@ import { cn } from "@/lib/utils"; import { apiPost } from "@/lib/api/api-fetch"; import { localizedApiError } from "@/lib/api/localized-error"; import { queryKeys } from "@/lib/query-keys"; -import { DISCLAIMER_VERSION } from "@/lib/onboarding/disclaimer"; +import { + DISCLAIMER_VERSION, + isDisclaimerAcknowledgmentCurrent, +} from "@/lib/onboarding/disclaimer"; /** * v1.4.25 W14b-Content — onboarding welcome carousel. @@ -87,11 +90,15 @@ export function WelcomeCarousel() { const [active, setActive] = useState(0); const [advancing, setAdvancing] = useState(false); // v1.18.6 (DISC-02) — the one-time medical-disclaimer acknowledgment gates - // "Get started". Pre-checked for an account that already acknowledged (a - // re-walk of step 0) so a returning user is not re-asked. - const [acknowledged, setAcknowledged] = useState( - () => user?.disclaimerAcknowledgedAt != null, + // "Get started". Pre-checked only when the account acknowledged the + // CURRENT disclaimer version (a re-walk of step 0), so a returning user is + // not re-asked — but a revised disclaimer re-prompts instead of riding an + // acknowledgment given for older text. + const disclaimerCurrent = isDisclaimerAcknowledgmentCurrent( + user?.disclaimerAcknowledgedAt, + user?.disclaimerAcknowledgedVersion, ); + const [acknowledged, setAcknowledged] = useState(() => disclaimerCurrent); // Track which slide is centred in the rail. We observe each slide // with an IntersectionObserver — simpler and more reliable than @@ -146,9 +153,10 @@ export function WelcomeCarousel() { if (advancing || !acknowledged) return; setAdvancing(true); try { - // v1.18.6 (DISC-02) — record the one-time acknowledgment before - // advancing. Skipped when the account already acknowledged (re-walk). - if (user?.disclaimerAcknowledgedAt == null) { + // v1.18.6 (DISC-02) — record the acknowledgment before advancing. + // Skipped only when the account already acknowledged the CURRENT + // version (re-walk); an older-version acknowledgment re-records. + if (!disclaimerCurrent) { await apiPost("/api/onboarding/disclaimer", { version: DISCLAIMER_VERSION, }); diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index 44e884d5a..39eaba9dc 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -89,6 +89,13 @@ export interface AuthUser { * rather than failing the shape. */ disclaimerAcknowledgedAt?: string | null; + /** + * The disclaimer version that acknowledgment was given for. The welcome + * gate compares it against the current `DISCLAIMER_VERSION` so a revised + * disclaimer re-prompts instead of riding an old acknowledgment. Optional + * for the same stale-payload reason as the timestamp above. + */ + disclaimerAcknowledgedVersion?: string | null; /** * v1.5.5 — relative URL of the user's self-hosted avatar, served * from `/api/user/avatar/{id}?v={updatedAtMs}`. Replaces the diff --git a/src/lib/analytics/__tests__/merge-slim-thick.test.ts b/src/lib/analytics/__tests__/merge-slim-thick.test.ts index 57384a8e5..3f104c015 100644 --- a/src/lib/analytics/__tests__/merge-slim-thick.test.ts +++ b/src/lib/analytics/__tests__/merge-slim-thick.test.ts @@ -29,7 +29,6 @@ const stubSummary = (): DataSummary => ({ avg30: 77.9, slope7: null, slope30: { slope: -0.05, direction: "down", confidence: 0.5 }, - slope90: null, anomalyCount: 0, }); diff --git a/src/lib/analytics/__tests__/summaries-slice.test.ts b/src/lib/analytics/__tests__/summaries-slice.test.ts index 2d18dcbd6..c1848be24 100644 --- a/src/lib/analytics/__tests__/summaries-slice.test.ts +++ b/src/lib/analytics/__tests__/summaries-slice.test.ts @@ -132,8 +132,6 @@ describe("computeSummariesSlice", () => { avg30: null, slope7: null, slope30: null, - slope90: null, - anomalyCount: 0, avg30LastMonth: null, avg30LastYear: null, }); @@ -187,7 +185,10 @@ describe("computeSummariesSlice", () => { expect(weight.mean).toBe(82.05); expect(weight.avg7).toBe(81.9); expect(weight.avg30).toBe(82.1); - expect(weight.anomalyCount).toBe(0); + // anomalyCount / slope90 left the wire shape in v1.37.19 — computed + // by the insights pipeline's own reads, never serialised here. + expect("anomalyCount" in weight).toBe(false); + expect("slope90" in weight).toBe(false); expect(weight.avg30LastMonth).toBeNull(); expect(weight.avg30LastYear).toBeNull(); expect(weight.slope7).toEqual({ @@ -200,11 +201,6 @@ describe("computeSummariesSlice", () => { direction: "stable", confidence: 0.42, }); - expect(weight.slope90).toEqual({ - slope: 0.001, - direction: "stable", - confidence: 0.12, - }); }); it("returns a null slope tuple when the SQL slope is null (insufficient rows)", async () => { @@ -238,7 +234,6 @@ describe("computeSummariesSlice", () => { const result = await computeSummariesSlice("user-1"); expect(result.summaries.PULSE.slope7).toBeNull(); expect(result.summaries.PULSE.slope30).toBeNull(); - expect(result.summaries.PULSE.slope90).toBeNull(); }); it("surfaces lastSeenByType from the DISTINCT ON pass's measured_at", async () => { @@ -681,17 +676,18 @@ describe("computeSummariesSlice", () => { expect(windowedSql).toBeDefined(); const sql = windowedSql as string; - // Every regression window (7/30/90 for slope + r²) anchors on the - // day-truncated UTC boundary. Six FILTERs total. - for (const days of ["7 days", "30 days", "90 days"]) { + // Every regression window (7/30 for slope + r²) anchors on the + // day-truncated UTC boundary. Four FILTERs total (the 90-day pair + // left with slope90 in v1.37.19). + for (const days of ["7 days", "30 days"]) { expect(sql).toContain( `(date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '${days}') AT TIME ZONE 'UTC'`, ); } const anchored = sql.match( - /date_trunc\('day', NOW\(\) AT TIME ZONE 'UTC'\) - INTERVAL '(?:7|30|90) days'\) AT TIME ZONE 'UTC'/g, + /date_trunc\('day', NOW\(\) AT TIME ZONE 'UTC'\) - INTERVAL '(?:7|30) days'\) AT TIME ZONE 'UTC'/g, ); - expect(anchored?.length).toBe(6); + expect(anchored?.length).toBe(4); // The regression windows must NOT fall back to the bare wall-clock // `NOW() - INTERVAL 'N days'` bound the warm path never uses for the diff --git a/src/lib/analytics/summaries-slice.ts b/src/lib/analytics/summaries-slice.ts index f476826ab..5bedf39a4 100644 --- a/src/lib/analytics/summaries-slice.ts +++ b/src/lib/analytics/summaries-slice.ts @@ -41,8 +41,9 @@ * Fields the slim shape intentionally omits (the dashboard never * reads them on first paint): * - `anomalyCount`: would require an extra per-type read for the - * z-score loop. The Coach / insights paths still get it via the - * default slice. + * z-score loop. v1.37.19 removed it from the wire shape entirely — + * the insights pipeline computes its own via `summarize()` / + * the comprehensive aggregator, and no client component reads it. * - `avg30LastMonth` / `avg30LastYear`: the dashboard tile delta * callout uses them, but only when the comparison-baseline * widget is enabled — that path already pre-fetches the default @@ -172,8 +173,6 @@ interface WindowedAggregateRow extends NarrowAggregateRow { r2_7: number | null; slope30: number | null; r2_30: number | null; - slope90: number | null; - r2_90: number | null; } interface LatestRow { @@ -222,8 +221,6 @@ function emptySummary(): DataSummary { avg30: null, slope7: null, slope30: null, - slope90: null, - anomalyCount: 0, avg30LastMonth: null, avg30LastYear: null, }; @@ -378,7 +375,7 @@ async function withSleepNightTotals( // Preserve the slope tuples the night summary doesn't compute (the // dashboard tile reads slope30); recompute them off the night series is // out of scope here — the per-night `summarize()` already fills slope7 / - // slope30 / slope90 from the night DataPoints, so use them directly. + // slope30 from the night DataPoints, so use them directly. slice.summaries.SLEEP_DURATION = summary; // Additive, observational: the latest night's main session saw two // writer buckets with clearly different asleep totals. Round to whole @@ -656,7 +653,6 @@ async function computeFromRollups(userId: string): Promise { } const reg7 = composeWindowedRegression(accBuckets, since7); const reg30 = composeWindowedRegression(accBuckets, since30); - const reg90 = composeWindowedRegression(accBuckets, since90); summaries[row.type] = { count: row.count, latest, @@ -668,8 +664,6 @@ async function computeFromRollups(userId: string): Promise { avg30: round2(narrow?.avg30 ?? null), slope7: buildSlope(reg7.slope, reg7.r2), slope30: buildSlope(reg30.slope, reg30.r2), - slope90: buildSlope(reg90.slope, reg90.r2), - anomalyCount: 0, avg30LastMonth: round2(narrow?.avg30_last_month ?? null), avg30LastYear: null, }; @@ -833,19 +827,7 @@ async function computeFromLiveAggregate( EXTRACT(EPOCH FROM m."measured_at") / 86400.0 ) FILTER ( WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '30 days') AT TIME ZONE 'UTC' - )::double precision AS r2_30, - REGR_SLOPE( - m."value", - EXTRACT(EPOCH FROM m."measured_at") / 86400.0 - ) FILTER ( - WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '90 days') AT TIME ZONE 'UTC' - )::double precision AS slope90, - REGR_R2( - m."value", - EXTRACT(EPOCH FROM m."measured_at") / 86400.0 - ) FILTER ( - WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '90 days') AT TIME ZONE 'UTC' - )::double precision AS r2_90 + )::double precision AS r2_30 FROM ${canonicalMeasurementsFrom(rankUnqualified, "90 days")} GROUP BY m."type" `, @@ -925,8 +907,6 @@ async function computeFromLiveAggregate( avg30: round2(win?.avg30 ?? null), slope7: buildSlope(win?.slope7 ?? null, win?.r2_7 ?? null), slope30: buildSlope(win?.slope30 ?? null, win?.r2_30 ?? null), - slope90: buildSlope(win?.slope90 ?? null, win?.r2_90 ?? null), - anomalyCount: 0, avg30LastMonth: round2(win?.avg30_last_month ?? null), avg30LastYear: null, }; @@ -974,7 +954,7 @@ async function computeFromLiveAggregate( * Why this helper exists * ---------------------- * The slim `computeSummariesSlice` caps its windowed columns at 90 d - * (`slope7 / slope30 / slope90`). The v1.5 multi-year trend feature + * (`slope7 / slope30`). The v1.5 multi-year trend feature * + the Coach drawer's "history" tile need linearly composable stats * (count / min / max / mean / sum) over much larger windows — 1 y, * 2 y, 3 y. Hitting the live `measurements` table for a 3-year span diff --git a/src/lib/analytics/trends.ts b/src/lib/analytics/trends.ts index c87402820..242f30bea 100644 --- a/src/lib/analytics/trends.ts +++ b/src/lib/analytics/trends.ts @@ -143,8 +143,15 @@ export interface DataSummary { avg30: number | null; slope7: TrendSlope | null; slope30: TrendSlope | null; - slope90: TrendSlope | null; - anomalyCount: number; + /** + * In-process only — the insights feature builder reads it as the + * outlier/anomaly figure for its prompt blocks. The `/api/analytics` + * wire never serialises it (the slim SQL path cannot compute it and + * always reported 0), and no client component reads it. `slope90` was + * removed from this shape entirely in v1.37.19 for the same reason: + * computed on every request, read by nobody. + */ + anomalyCount?: number; /** * v1.4.16 phase B8 — average value over the 30-day window starting * 30 days before today, i.e. the "last month" prior period the @@ -176,7 +183,6 @@ export function summarize(data: DataPoint[]): DataSummary { avg30: null, slope7: null, slope30: null, - slope90: null, anomalyCount: 0, avg30LastMonth: null, avg30LastYear: null, @@ -267,7 +273,6 @@ export function summarize(data: DataPoint[]): DataSummary { : null, slope7: trendSlope(data, 7), slope30: trendSlope(data, 30), - slope90: trendSlope(data, 90), anomalyCount: detectAnomalies(data).length, avg30LastMonth, avg30LastYear, diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts index 73ca46678..56b551b16 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -167,7 +167,6 @@ const emptySummary = { avg30: null, slope7: null, slope30: null, - slope90: null, anomalyCount: 0, }; diff --git a/src/lib/insights/__tests__/comprehensive-aggregator.test.ts b/src/lib/insights/__tests__/comprehensive-aggregator.test.ts index 07c377a9a..131104667 100644 --- a/src/lib/insights/__tests__/comprehensive-aggregator.test.ts +++ b/src/lib/insights/__tests__/comprehensive-aggregator.test.ts @@ -327,8 +327,6 @@ describe("buildComprehensiveAggregate", () => { r2_7: 0.65, slope30: -0.005, r2_30: 0.42, - slope90: 0.001, - r2_90: 0.12, }, ]) .mockResolvedValueOnce([ @@ -360,11 +358,6 @@ describe("buildComprehensiveAggregate", () => { direction: "stable", confidence: 0.42, }); - expect(weight.slope90).toEqual({ - slope: 0.001, - direction: "stable", - confidence: 0.12, - }); expect(result.totalMeasurements).toBe(42); expect(result.firstMeasurementAt).toBeInstanceOf(Date); @@ -400,8 +393,6 @@ describe("buildComprehensiveAggregate", () => { r2_7: null, slope30: null, r2_30: null, - slope90: null, - r2_90: null, }, ]) .mockResolvedValueOnce([ diff --git a/src/lib/insights/__tests__/metric-availability.test.ts b/src/lib/insights/__tests__/metric-availability.test.ts index 85cf8f031..9b6575167 100644 --- a/src/lib/insights/__tests__/metric-availability.test.ts +++ b/src/lib/insights/__tests__/metric-availability.test.ts @@ -24,7 +24,6 @@ function fakeSummary(count: number): DataSummary { avg30: null, slope7: null, slope30: null, - slope90: null, anomalyCount: 0, }; } diff --git a/src/lib/insights/comprehensive-aggregator.ts b/src/lib/insights/comprehensive-aggregator.ts index c96c1b736..9f10da2e0 100644 --- a/src/lib/insights/comprehensive-aggregator.ts +++ b/src/lib/insights/comprehensive-aggregator.ts @@ -44,7 +44,7 @@ * * - A **narrow** `$queryRaw` still fires for the non-composable * fields: `stddev`, `anomalyCount`, `avg7 / avg30 / avg30LastMonth`, - * and the `slope7 / slope30 / slope90` tuples. These don't compose + * and the `slope7 / slope30` tuples. These don't compose * linearly across DAY buckets so live SQL stays canonical, but the * query no longer carries the redundant `COUNT(*)` / `MIN` / `MAX` * / `AVG` columns that duplicated the bucket-derived values. @@ -145,8 +145,6 @@ interface HeavyAggregateRow { r2_7: number | null; slope30: number | null; r2_30: number | null; - slope90: number | null; - r2_90: number | null; } /** @@ -569,7 +567,6 @@ async function buildFromRollups( } const reg7 = composeWindowedRegression(accBuckets, since7); const reg30 = composeWindowedRegression(accBuckets, since30); - const reg90 = composeWindowedRegression(accBuckets, since90); summaries[type] = { count: composed.count, latest: latest?.value ?? null, @@ -585,7 +582,6 @@ async function buildFromRollups( avg30: round2(narrow?.avg30 ?? null), slope7: buildSlope(reg7.slope, reg7.r2), slope30: buildSlope(reg30.slope, reg30.r2), - slope90: buildSlope(reg90.slope, reg90.r2), anomalyCount: Number(narrow?.anomaly_count ?? 0), avg30LastMonth: round2(narrow?.avg30_last_month ?? null), // Legacy semantics: the 90-day window guarantees no rows from a @@ -732,19 +728,7 @@ async function buildFromLiveAggregate( EXTRACT(EPOCH FROM m."measured_at") / 86400.0 ) FILTER ( WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '30 days') AT TIME ZONE 'UTC' - )::double precision AS r2_30, - REGR_SLOPE( - m."value", - EXTRACT(EPOCH FROM m."measured_at") / 86400.0 - ) FILTER ( - WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '90 days') AT TIME ZONE 'UTC' - )::double precision AS slope90, - REGR_R2( - m."value", - EXTRACT(EPOCH FROM m."measured_at") / 86400.0 - ) FILTER ( - WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '90 days') AT TIME ZONE 'UTC' - )::double precision AS r2_90 + )::double precision AS r2_30 FROM cm m JOIN window_stats ws ON ws."type" = m."type" GROUP BY m."type", ws.stddev_value @@ -819,7 +803,6 @@ async function buildFromLiveAggregate( avg30: round2(row.avg30), slope7: buildSlope(row.slope7, row.r2_7), slope30: buildSlope(row.slope30, row.r2_30), - slope90: buildSlope(row.slope90, row.r2_90), anomalyCount: Number(row.anomaly_count), avg30LastMonth: round2(row.avg30_last_month), avg30LastYear: null, diff --git a/src/lib/insights/features.ts b/src/lib/insights/features.ts index b0598de15..ed1fa38b7 100644 --- a/src/lib/insights/features.ts +++ b/src/lib/insights/features.ts @@ -1101,7 +1101,7 @@ export async function extractFeatures( ? summary.max : null, slope30: summary.slope30?.slope ?? null, - outlierCount: summary.anomalyCount, + outlierCount: summary.anomalyCount ?? 0, bmi, coverage: computeCoverage(weightData, now), }; @@ -1232,7 +1232,7 @@ export async function extractFeatures( ? summary.max : null, slope30: summary.slope30?.slope ?? null, - anomalyCount: summary.anomalyCount, + anomalyCount: summary.anomalyCount ?? 0, coverage: computeCoverage(pulseData, now), }; } diff --git a/src/lib/jobs/__tests__/coach-reminder-sweep.test.ts b/src/lib/jobs/__tests__/coach-reminder-sweep.test.ts index 30e089b45..015b10d74 100644 --- a/src/lib/jobs/__tests__/coach-reminder-sweep.test.ts +++ b/src/lib/jobs/__tests__/coach-reminder-sweep.test.ts @@ -237,6 +237,46 @@ describe("runCoachReminderSweep", () => { expect(where.contextCue).toEqual({ not: "NEXT_APP_OPEN" }); }); + // Watched red: with the nag-cap pass removed from the sweep this fails on + // the `nagDismissed` assertion and on the missing `dismissed` updateMany — + // the pre-fix sweep never enforced the cap the surfaceCount schema comment + // promised, so an ignored reminder nagged indefinitely. + it("charges every ignored day and auto-dismisses at the nag cap", async () => { + const prisma = makePrisma({}); + prisma.coachReminder.updateMany + .mockResolvedValueOnce({ count: 2 }) // increment pass + .mockResolvedValueOnce({ count: 1 }); // dismiss pass + + const summary = await runCoachReminderSweep(prisma as never, NOW); + expect(summary.nagDismissed).toBe(1); + + const [incrementCall, dismissCall] = prisma.coachReminder.updateMany.mock + .calls as unknown as [ + [ + { + where: { status: string; lastSurfacedAt: { lte: Date } }; + data: { surfaceCount: { increment: number } }; + }, + ], + [ + { + where: { status: string; surfaceCount: { gte: number } }; + data: { status: string }; + }, + ], + ]; + // Only rows already stale for ~a day are charged — the tick that wrote + // the surfacing message must not also count the first ignored day. + expect(incrementCall[0].where.status).toBe("surfaced"); + expect(incrementCall[0].where.lastSurfacedAt.lte.getTime()).toBeLessThan( + NOW.getTime(), + ); + expect(incrementCall[0].data.surfaceCount).toEqual({ increment: 1 }); + // At the cap the row flips to `dismissed` — silence, not another nag. + expect(dismissCall[0].where.surfaceCount).toEqual({ gte: 3 }); + expect(dismissCall[0].data.status).toBe("dismissed"); + }); + it("skips an undecryptable plan without sinking the tick", async () => { const prisma = makePrisma({ plans: [ diff --git a/src/lib/jobs/coach-reminder-sweep.ts b/src/lib/jobs/coach-reminder-sweep.ts index bd89611cf..a15ff20a5 100644 --- a/src/lib/jobs/coach-reminder-sweep.ts +++ b/src/lib/jobs/coach-reminder-sweep.ts @@ -64,12 +64,30 @@ const SURFACE_BATCH = 500; /** Bound the context-backstop fan-out per tick for the same reason. */ const CONTEXT_BACKSTOP_BATCH = 500; +/** + * How many surfacings an untouched reminder gets before it auto-dismisses — + * the cap the `surfaceCount` schema comment has promised since v1.22. A + * `surfaced` row keeps riding the prompt injection and the suggested-actions + * rail; every daily tick it is still sitting there counts as one more + * surfacing, and at the cap the row flips to `dismissed` so an ignored + * reminder cannot nag indefinitely. + */ +export const REMINDER_NAG_CAP = 3; +/** + * A row surfaced within this window is not re-counted — the tick that wrote + * the surfacing message must not also charge the first "still ignored" day. + * 20 h (not 24) so the daily 05:20 cron never misses by clock drift. + */ +const NAG_STALE_MS = 20 * 60 * 60 * 1000; + export interface CoachReminderSweepSummary { /** Reminders whose moment arrived AND that reached the conversation. */ remindersDue: number; planReviewsMinted: number; /** Context-cue reminders the daily backstop surfaced. */ contextSurfaced: number; + /** Ignored reminders auto-dismissed at the nag cap. */ + nagDismissed: number; errored: number; } @@ -97,6 +115,7 @@ export async function runCoachReminderSweep( remindersDue: 0, planReviewsMinted: 0, contextSurfaced: 0, + nagDismissed: 0, errored: 0, }; @@ -232,5 +251,30 @@ export async function runCoachReminderSweep( } } + // ── 4. nag cap ──────────────────────────────────────────────── + // + // A `surfaced` reminder stays in the prompt injection and the + // suggested-actions rail until the user acts on it. Charge every tick + // it has already sat there for a day as one more surfacing, then + // auto-dismiss the rows that reached the cap. Two bulk statements, no + // message: dismissal is silence, not another nag. + await prisma.coachReminder.updateMany({ + where: { + deletedAt: null, + status: "surfaced", + lastSurfacedAt: { lte: new Date(now.getTime() - NAG_STALE_MS) }, + }, + data: { surfaceCount: { increment: 1 } }, + }); + const capped = await prisma.coachReminder.updateMany({ + where: { + deletedAt: null, + status: "surfaced", + surfaceCount: { gte: REMINDER_NAG_CAP }, + }, + data: { status: "dismissed" }, + }); + summary.nagDismissed = capped.count; + return summary; } diff --git a/src/lib/jobs/reminder/register-status.ts b/src/lib/jobs/reminder/register-status.ts index 22cff8e9b..8ea3a7a6f 100644 --- a/src/lib/jobs/reminder/register-status.ts +++ b/src/lib/jobs/reminder/register-status.ts @@ -845,6 +845,7 @@ export async function registerStatusQueues( reminders_due: summary.remindersDue, plan_reviews_minted: summary.planReviewsMinted, context_surfaced: summary.contextSurfaced, + nag_dismissed: summary.nagDismissed, errored: summary.errored, }, }); @@ -852,6 +853,7 @@ export async function registerStatusQueues( reminders_due: summary.remindersDue, plan_reviews_minted: summary.planReviewsMinted, context_surfaced: summary.contextSurfaced, + nag_dismissed: summary.nagDismissed, errored: summary.errored, }); } catch (err) { diff --git a/src/lib/onboarding/__tests__/disclaimer.test.ts b/src/lib/onboarding/__tests__/disclaimer.test.ts new file mode 100644 index 000000000..d7a9cf2de --- /dev/null +++ b/src/lib/onboarding/__tests__/disclaimer.test.ts @@ -0,0 +1,44 @@ +/** + * The disclaimer-version gate (welcome step 0). + * + * Watched red: with `isDisclaimerAcknowledgmentCurrent` reduced to the + * pre-fix timestamp-only check (`acknowledgedAt != null`) the stale-version + * case below fails — the version column was written on acknowledgment but + * never compared, so bumping `DISCLAIMER_VERSION` could not re-prompt. + */ +import { describe, expect, it } from "vitest"; + +import { + DISCLAIMER_VERSION, + isDisclaimerAcknowledgmentCurrent, +} from "../disclaimer"; + +describe("isDisclaimerAcknowledgmentCurrent", () => { + it("holds for an acknowledgment of the current version", () => { + expect( + isDisclaimerAcknowledgmentCurrent( + "2026-06-20T08:00:00.000Z", + DISCLAIMER_VERSION, + ), + ).toBe(true); + }); + + it("re-prompts when the stored acknowledgment is for an older version", () => { + expect( + isDisclaimerAcknowledgmentCurrent("2026-06-20T08:00:00.000Z", "2025-01-01"), + ).toBe(false); + }); + + it("re-prompts when the version was never recorded", () => { + expect( + isDisclaimerAcknowledgmentCurrent("2026-06-20T08:00:00.000Z", null), + ).toBe(false); + }); + + it("requires an acknowledgment timestamp at all", () => { + expect(isDisclaimerAcknowledgmentCurrent(null, DISCLAIMER_VERSION)).toBe( + false, + ); + expect(isDisclaimerAcknowledgmentCurrent(undefined, undefined)).toBe(false); + }); +}); diff --git a/src/lib/onboarding/disclaimer.ts b/src/lib/onboarding/disclaimer.ts index 02f37ef2d..df8d4c835 100644 --- a/src/lib/onboarding/disclaimer.ts +++ b/src/lib/onboarding/disclaimer.ts @@ -7,3 +7,18 @@ * not on a typo fix. */ export const DISCLAIMER_VERSION = "2026-06-18"; + +/** + * Whether a stored acknowledgment covers the CURRENT disclaimer text. + * + * The welcome gate pre-checks its checkbox (and skips the re-record) only + * when this holds. Until v1.37.19 the gate looked at the timestamp alone, + * which meant the version column was written but never compared — bumping + * `DISCLAIMER_VERSION` could not re-prompt anyone. + */ +export function isDisclaimerAcknowledgmentCurrent( + acknowledgedAt: string | Date | null | undefined, + acknowledgedVersion: string | null | undefined, +): boolean { + return acknowledgedAt != null && acknowledgedVersion === DISCLAIMER_VERSION; +}