Enforce the reminder nag cap, compare the disclaimer version, drop the unread analytics columns

- 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.
This commit is contained in:
Marc-André Bombeck committed 2026-08-14 05:19:05 +02:00
1 parent 6b3a5a4182
commit 3b1314d1b0
29 files changed
+214 -104

No files matched your search

-1
View File
@@ -116,7 +116,6 @@ function summary(partial: Partial<DataSummary>): DataSummary {
avg30: null,
slope7: null,
slope30: null,
slope90: null,
anomalyCount: 0,
avg30LastMonth: null,
avg30LastYear: null,
@@ -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)
+9 -7
View File
@@ -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;
+4
View File
@@ -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
+1 -1
View File
@@ -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,
});
+1 -1
View File
@@ -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,
+1 -2
View File
@@ -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),
};
}
@@ -64,7 +64,6 @@ function summary(over: Partial<DataSummary>): DataSummary {
avg30: null,
slope7: null,
slope30: null,
slope90: null,
anomalyCount: 0,
...over,
};
@@ -52,7 +52,7 @@ describe("<InsightsEditMode> — detail-page manager retired (v1.15.20)", () =>
expect(html).toContain(
'href="/settings/layout/insights#insights-pill-order"',
);
expect(html).toContain("Manage pills &amp; detail pages in Settings");
expect(html).toContain("Manage chips &amp; detail pages in Settings");
});
it("still renders the section rows with their eye toggles", () => {
@@ -25,7 +25,6 @@ function fakeSummary(count: number): DataSummary {
avg30: null,
slope7: null,
slope30: null,
slope90: null,
anomalyCount: 0,
};
}
@@ -36,7 +36,6 @@ function fakeSummary(count: number): DataSummary {
avg30: null,
slope7: null,
slope30: null,
slope90: null,
anomalyCount: 0,
};
}
@@ -36,7 +36,6 @@ const populated: DataSummary = {
avg30: 70,
slope7: null,
slope30: null,
slope90: null,
anomalyCount: 0,
avg30LastMonth: null,
avg30LastYear: null,
@@ -20,7 +20,6 @@ function summary(count: number): DataSummary {
avg30: null,
slope7: null,
slope30: null,
slope90: null,
anomalyCount: 0,
};
}
+16 -8
View File
@@ -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,
});
+7
View File
@@ -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
@@ -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,
});
@@ -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
+6 -26
View File
@@ -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<SummariesSlice> {
}
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<SummariesSlice> {
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
+9 -4
View File
@@ -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,
@@ -167,7 +167,6 @@ const emptySummary = {
avg30: null,
slope7: null,
slope30: null,
slope90: null,
anomalyCount: 0,
};
@@ -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([
@@ -24,7 +24,6 @@ function fakeSummary(count: number): DataSummary {
avg30: null,
slope7: null,
slope30: null,
slope90: null,
anomalyCount: 0,
};
}
+2 -19
View File
@@ -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,
+2 -2
View File
@@ -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),
};
}
@@ -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: [
+44
View File
@@ -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;
}
+2
View File
@@ -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) {
@@ -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);
});
});
+15
View File
@@ -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;
}