mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
6b3a5a4182
commit
3b1314d1b0
29 files changed
+214
-104
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 & detail pages in Settings");
|
||||
expect(html).toContain("Manage chips & 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user