Merge branch 'main' into fix/handover-p1-batch

This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-08-07 18:24:33 +02:00
commit 384992bb15
34 files changed
+1305 -383

No files matched your search

+83 -15
View File
@@ -1,10 +1,20 @@
import { describe, it, expect } from "vitest";
import {
LAB_CHANGE_RECENCY_DAYS,
summariseLabChanges,
type LabChangeRow,
} from "@/lib/insights/labs-changes";
/**
* Every case pins `now`. The fixtures are fixed calendar days and the summary
* has a relative freshness window, so a floating `new Date()` would leave the
* pairing case green until the day it silently slid out of the window and then
* fail for a reason that has nothing to do with the code under test.
*/
const NOW = new Date("2026-06-15T09:00:00.000Z");
const DAY_MS = 24 * 60 * 60 * 1000;
function row(
analyte: string,
value: number,
@@ -23,28 +33,31 @@ function row(
describe("summariseLabChanges", () => {
it("is absent with no rows", () => {
expect(summariseLabChanges([])).toMatchObject({ present: false });
expect(summariseLabChanges([], NOW)).toMatchObject({ present: false });
});
it("is absent with a single panel", () => {
const s = summariseLabChanges([row("LDL", 130, "2026-06-01")]);
const s = summariseLabChanges([row("LDL", 130, "2026-06-01")], NOW);
expect(s.present).toBe(false);
});
it("is absent when no analyte is shared across panels", () => {
const s = summariseLabChanges([
row("LDL", 130, "2026-06-01"),
row("HbA1c", 5.4, "2026-05-01"),
]);
const s = summariseLabChanges(
[row("LDL", 130, "2026-06-01"), row("HbA1c", 5.4, "2026-05-01")],
NOW,
);
expect(s.present).toBe(false);
});
it("pairs the two most-recent panels for a shared analyte", () => {
const s = summariseLabChanges([
row("LDL", 120, "2026-06-01", { high: 116 }),
row("LDL", 140, "2026-05-01"),
row("LDL", 150, "2026-04-01"),
]);
const s = summariseLabChanges(
[
row("LDL", 120, "2026-06-01", { high: 116 }),
row("LDL", 140, "2026-05-01"),
row("LDL", 150, "2026-04-01"),
],
NOW,
);
expect(s.present).toBe(true);
expect(s.latestDate).toBe("2026-06-01");
expect(s.previousDate).toBe("2026-05-01");
@@ -58,10 +71,65 @@ describe("summariseLabChanges", () => {
});
it("skips qualitative (non-finite) values", () => {
const s = summariseLabChanges([
row("LDL", Number.NaN, "2026-06-01"),
row("LDL", 140, "2026-05-01"),
]);
const s = summariseLabChanges(
[row("LDL", Number.NaN, "2026-06-01"), row("LDL", 140, "2026-05-01")],
NOW,
);
expect(s.present).toBe(false);
});
// The comparison is a claim about what changed "since your last panel". Past
// the window that sentence is no longer true of anybody's data, so the
// summary reports absent rather than presenting a years-old delta as news.
it("is present while the newest panel is inside the recency window", () => {
const latest = new Date(
NOW.getTime() - (LAB_CHANGE_RECENCY_DAYS - 1) * DAY_MS,
);
const previous = new Date(latest.getTime() - 30 * DAY_MS);
const s = summariseLabChanges(
[
{ ...row("LDL", 120, "2026-01-01"), takenAt: latest },
{ ...row("LDL", 140, "2026-01-01"), takenAt: previous },
],
NOW,
);
expect(s.present).toBe(true);
expect(s.changes).toHaveLength(1);
});
it("is absent once the newest panel falls outside it", () => {
const latest = new Date(
NOW.getTime() - (LAB_CHANGE_RECENCY_DAYS + 1) * DAY_MS,
);
const previous = new Date(latest.getTime() - 30 * DAY_MS);
const s = summariseLabChanges(
[
{ ...row("LDL", 120, "2026-01-01"), takenAt: latest },
{ ...row("LDL", 140, "2026-01-01"), takenAt: previous },
],
NOW,
);
expect(s.present).toBe(false);
expect(s.changes).toEqual([]);
expect(s.latestDate).toBeNull();
});
// The boundary is read off the sample instant, not off the UTC day key: a
// panel drawn in the evening east of UTC keys to the NEXT calendar day, and
// re-parsing that key would move the cut-off by hours in one direction and
// hours the other way for anyone west of it.
it("measures the window from the sample instant, not the calendar-day key", () => {
const latest = new Date(
NOW.getTime() - LAB_CHANGE_RECENCY_DAYS * DAY_MS + 60_000,
);
const previous = new Date(latest.getTime() - 30 * DAY_MS);
const s = summariseLabChanges(
[
{ ...row("LDL", 120, "2026-01-01"), takenAt: latest },
{ ...row("LDL", 140, "2026-01-01"), takenAt: previous },
],
NOW,
);
expect(s.present).toBe(true);
});
});
+37 -2
View File
@@ -9,8 +9,9 @@
* numeric value, so they are skipped — a delta is meaningless for them.
*
* Pure + Prisma-free so the present / absent states are unit-testable: it is
* absent when there are fewer than two panel dates or no analyte is shared.
* Neutral framing only — a delta is not a diagnosis.
* absent when there are fewer than two panel dates, no analyte is shared, or
* the newest panel is older than `LAB_CHANGE_RECENCY_DAYS`. Neutral framing
* only — a delta is not a diagnosis.
*/
import {
classifyReferenceRange,
@@ -47,6 +48,29 @@ export interface LabChangesSummary {
changes: LabChange[];
}
/**
* How long the comparison keeps calling itself "since your last panel".
*
* The card's whole claim is time-bound: a delta between the two newest panels
* is news for a while, and then it is simply history the labs page already
* holds. Someone whose newest panel is years old was still being shown "what
* changed since your last panel", which is not a stale piece of UI state — it
* is a stale STATEMENT.
*
* A year, and not less, because routine panels are commonly drawn annually. A
* shorter window would hide a comparison that is genuinely the newest thing
* the person has for months of every year. Past a year the wording cannot be
* defended at all, so the comparison stops being surfaced rather than being
* re-worded into something nobody asked for.
*
* Expressed on the DATA, not per user: nothing to acknowledge, nothing to
* persist, no housekeeping asked of anyone, and every client — web and iOS —
* reads the same answer through the existing `present: false` contract.
*/
export const LAB_CHANGE_RECENCY_DAYS = 365;
const MS_PER_DAY = 24 * 60 * 60 * 1000;
const ABSENT: LabChangesSummary = {
present: false,
latestDate: null,
@@ -73,6 +97,7 @@ const round2 = (n: number): number => Math.round(n * 100) / 100;
*/
export function summariseLabChanges(
rows: readonly LabChangeRow[],
now: Date = new Date(),
): LabChangesSummary {
const numeric = rows.filter((r) => Number.isFinite(r.value));
if (numeric.length === 0) return ABSENT;
@@ -101,6 +126,16 @@ export function summariseLabChanges(
const latest = byDay.get(latestDate)!;
const previous = byDay.get(previousDate)!;
// Read the freshness off the newest actual sample instant in that panel, not
// off the day key: the key is a UTC calendar day and re-parsing it would put
// the boundary a few hours out for anyone east or west of UTC.
const newestSampleAt = Math.max(
...[...latest.values()].map((r) => r.takenAt.getTime()),
);
if (now.getTime() - newestSampleAt > LAB_CHANGE_RECENCY_DAYS * MS_PER_DAY) {
return ABSENT;
}
const changes: LabChange[] = [];
for (const [key, latestRow] of latest) {
const previousRow = previous.get(key);
@@ -1,10 +1,12 @@
/**
* v1.22 (M4) — daily Coach-reminder sweep.
*
* Covers: overdue active date-reminders flip to `due`; a passed CoachPlan
* reviewDate mints a one-off reminder from the plan's own cue→action text and
* clears the reviewDate (activating the dangling B1 column); an undecryptable
* plan is skipped (counted errored) without sinking the tick.
* Covers: a passed CoachPlan reviewDate mints a one-off reminder from the
* plan's own cue→action text and clears the reviewDate (activating the dangling
* B1 column); an undecryptable plan is skipped (counted errored) without
* sinking the tick; and — v1.37 — surfacing writes the reminder into a
* conversation and flips its status in one transaction, so a badge can never
* point at a message that was never written.
*/
import { describe, it, expect, vi } from "vitest";
@@ -25,84 +27,179 @@ function bytes(tag: string): Uint8Array {
return new Uint8Array(Buffer.from(tag, "utf8"));
}
/**
* A prisma double whose `$transaction` accepts BOTH shapes the sweep uses: an
* array of pre-built operations (the plan-review mint) and an interactive
* callback (the surfacing step).
*/
function makePrisma(options: {
overdue?: {
id: string;
userId: string;
noteEncrypted: Uint8Array;
}[];
plans?: {
id: string;
userId: string;
metric: string;
ifCueEncrypted: Uint8Array;
thenActionEncrypted: Uint8Array;
}[];
}) {
const tx = {
coachConversation: { create: vi.fn(async () => ({ id: "c1" })) },
coachMessage: { create: vi.fn(async () => ({ id: "m1" })) },
coachReminder: { updateMany: vi.fn(async () => ({ count: 0 })) },
};
const prisma = {
coachReminder: {
findMany: vi.fn(async () => options.overdue ?? []),
create: vi.fn(async () => ({ id: "r-new" })),
updateMany: vi.fn(async () => ({ count: 0 })),
},
coachPlan: {
findMany: vi.fn(async () => options.plans ?? []),
update: vi.fn(async () => ({})),
},
coachConversation: { create: vi.fn(async () => ({ id: "c1" })) },
coachMessage: { create: vi.fn(async () => ({ id: "m1" })) },
user: { findUnique: vi.fn(async () => ({ locale: "en" })) },
$transaction: vi.fn(async (arg: unknown) => {
if (typeof arg === "function") {
return (arg as (t: typeof tx) => Promise<unknown>)(tx);
}
return Promise.all(arg as Promise<unknown>[]);
}),
tx,
};
return prisma;
}
describe("runCoachReminderSweep", () => {
it("flips overdue active date-reminders to due and mints plan-review reminders", async () => {
const prisma = {
coachReminder: {
updateMany: vi.fn(async () => ({ count: 3 })),
create: vi.fn(async () => ({ id: "r-new" })),
},
coachPlan: {
findMany: vi.fn(async () => [
{
id: "p1",
userId: "u1",
metric: "WEIGHT",
ifCueEncrypted: bytes("every morning"),
thenActionEncrypted: bytes("weigh in"),
},
]),
update: vi.fn(async () => ({})),
},
// The mint runs as one atomic batch; resolve the built ops together.
$transaction: vi.fn(async (ops: Promise<unknown>[]) => Promise.all(ops)),
};
it("mints a plan-review reminder from the plan's own prose and clears the reviewDate", async () => {
const prisma = makePrisma({
plans: [
{
id: "p1",
userId: "u1",
metric: "WEIGHT",
ifCueEncrypted: bytes("every morning"),
thenActionEncrypted: bytes("weigh in"),
},
],
});
const summary = await runCoachReminderSweep(prisma as never, NOW);
expect(summary.remindersDue).toBe(3);
expect(summary.planReviewsMinted).toBe(1);
expect(summary.errored).toBe(0);
// The flip query is scoped to active, date-triggered, overdue rows.
const flipArgs = prisma.coachReminder.updateMany.mock
.calls[0] as unknown as [
{ where: { status: string; triggerKind: string; dueAt: unknown } },
];
const where = flipArgs[0].where;
expect(where.status).toBe("active");
expect(where.triggerKind).toBe("date");
expect(where.dueAt).toEqual({ not: null, lte: NOW });
// The minted reminder carries the plan's own prose + relatedPlanId.
const createArgs = prisma.coachReminder.create.mock.calls[0] as unknown as [
{ data: { relatedPlanId: string; source: string; status: string } },
];
const data = createArgs[0].data;
expect(data.relatedPlanId).toBe("p1");
expect(data.source).toBe("extractor");
expect(data.status).toBe("due");
// Minted `active`, not `due`: the surfacing pass in this same tick is what
// takes it to the conversation, and there is only one such path.
expect(data.status).toBe("active");
// The plan's reviewDate is cleared so the review fires exactly once.
expect(prisma.coachPlan.update).toHaveBeenCalledWith({
where: { id: "p1" },
data: { reviewDate: null },
});
});
// The create + clear commit atomically — a half-applied mint would let the
// next tick re-select the plan and duplicate the review reminder.
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
it("writes the reminder into a conversation and flips its status in ONE transaction", async () => {
const prisma = makePrisma({
overdue: [
{ id: "r1", userId: "u1", noteEncrypted: bytes("ask about my sleep") },
],
});
const summary = await runCoachReminderSweep(prisma as never, NOW);
expect(summary.remindersDue).toBe(1);
expect(summary.errored).toBe(0);
// A conversation with the note as an ASSISTANT message — the row the
// unread badge is derived from.
expect(prisma.tx.coachConversation.create).toHaveBeenCalledTimes(1);
const msgArgs = prisma.tx.coachMessage.create.mock.calls[0] as unknown as [
{ data: { role: string; encryptedContent: Uint8Array } },
];
expect(msgArgs[0].data.role).toBe("assistant");
const body = Buffer.from(msgArgs[0].data.encryptedContent).toString("utf8");
expect(body).toContain("dec:ask about my sleep");
// …and the status flip rides the SAME transaction, so neither half can
// land without the other.
const flip = prisma.tx.coachReminder.updateMany.mock
.calls[0] as unknown as [
{ where: { id: { in: string[] } }; data: { status: string } },
];
expect(flip[0].where.id.in).toEqual(["r1"]);
expect(flip[0].data.status).toBe("surfaced");
});
it("picks up rows an older build left stuck on `due`", async () => {
const prisma = makePrisma({
overdue: [{ id: "r-old", userId: "u1", noteEncrypted: bytes("stuck") }],
});
await runCoachReminderSweep(prisma as never, NOW);
const where = (
prisma.coachReminder.findMany.mock.calls[0] as unknown as [
{ where: { status: { in: string[] } } },
]
)[0].where;
expect(where.status.in).toEqual(["active", "due"]);
});
it("leaves the reminder alone when the conversation write fails", async () => {
const prisma = makePrisma({
overdue: [{ id: "r1", userId: "u1", noteEncrypted: bytes("note") }],
});
prisma.$transaction = vi.fn(async (arg: unknown) => {
if (typeof arg === "function") throw new Error("db down");
return Promise.all(arg as Promise<unknown>[]);
}) as never;
const summary = await runCoachReminderSweep(prisma as never, NOW);
// Not counted as surfaced — the next tick retries, and until it succeeds
// nothing claims a message that does not exist.
expect(summary.remindersDue).toBe(0);
expect(summary.errored).toBe(1);
expect(prisma.tx.coachReminder.updateMany).not.toHaveBeenCalled();
});
it("skips an undecryptable note without losing the ones beside it", async () => {
const prisma = makePrisma({
overdue: [
{ id: "r-bad", userId: "u1", noteEncrypted: bytes("__bad__") },
{ id: "r-ok", userId: "u1", noteEncrypted: bytes("fine") },
],
});
const summary = await runCoachReminderSweep(prisma as never, NOW);
expect(summary.remindersDue).toBe(1);
expect(summary.errored).toBe(1);
const flip = prisma.tx.coachReminder.updateMany.mock
.calls[0] as unknown as [{ where: { id: { in: string[] } } }];
expect(flip[0].where.id.in).toEqual(["r-ok"]);
});
it("skips an undecryptable plan without sinking the tick", async () => {
const prisma = {
coachReminder: {
updateMany: vi.fn(async () => ({ count: 0 })),
create: vi.fn(async () => ({ id: "r-new" })),
},
coachPlan: {
findMany: vi.fn(async () => [
{
id: "p1",
userId: "u1",
metric: "SLEEP",
ifCueEncrypted: bytes("__bad__"),
thenActionEncrypted: bytes("lights out"),
},
]),
update: vi.fn(async () => ({})),
},
$transaction: vi.fn(async (ops: Promise<unknown>[]) => Promise.all(ops)),
};
const prisma = makePrisma({
plans: [
{
id: "p1",
userId: "u1",
metric: "SLEEP",
ifCueEncrypted: bytes("__bad__"),
thenActionEncrypted: bytes("lights out"),
},
],
});
const summary = await runCoachReminderSweep(prisma as never, NOW);
expect(summary.planReviewsMinted).toBe(0);
+161 -28
View File
@@ -5,8 +5,7 @@
* per tick that brings a reminder back at its chosen moment. Two sources:
*
* 1. `CoachReminder` rows that are date-triggered, `active`, and whose `dueAt`
* has passed → flipped to `due` (the in-app tile + indicator read `due` /
* `surfaced`). `lastSurfacedAt` is stamped + `surfaceCount` bumped.
* has passed.
* 2. `CoachPlan` rows whose dangling `reviewDate` (written nowhere, read
* nowhere until now — see the design §1c) has passed and that are still
* `active` → a one-off `CoachReminder` is minted from the plan's own
@@ -14,9 +13,30 @@
* `reviewDate` is cleared so the review fires once. This finally activates
* the column the B1 schema anticipated.
*
* In-app surfacing ONLY in this wave: push delivery + the receptivity gate (B4)
* land later. The sweep never calls a provider and never pushes — it only flips
* status + mints the plan-review reminder, both idempotent across re-fires.
* Both sources converge on ONE surfacing step, and that convergence is the
* point. Until v1.37 the sweep only flipped a reminder's status to `due` and
* stopped: nothing was written into the conversation, `surfaced` had no writer
* at all, and the FAB lit its unread dot off a separate "is anything due?"
* query. So the dot appeared over an empty Coach, opening it showed the empty
* state, and opening it did not clear the dot either — the only way out was to
* resolve the reminder in Settings, which nothing on the badge suggested. A
* badge and the content it promises must come from one event.
*
* They do now. Surfacing writes the reminder into the user's Coach thread as a
* real assistant message and flips the reminder to `surfaced` in the SAME
* transaction, so the two cannot drift: no message, no status change, no
* badge. The badge itself is derived from that message
* (`readCoachNudgeStatus` compares the newest assistant message against
* `User.coachLastSeenAt`), which means opening the Coach clears it the way it
* clears any other proactive message.
*
* Rows left `due` by an older build are picked up by the same pass, so a badge
* that has been stuck on an empty conversation resolves into the message it
* always implied rather than staying stuck.
*
* The sweep still never calls a provider and never pushes. The message body is
* a deterministic localized template around the user's OWN note text — the
* words they asked to be reminded of, never anything fabricated.
*
* Context-cue reminders (`triggerKind: "context"`) are NOT evaluated here — that
* is a follow-on (F4); the column exists and capture supports it, but the date
@@ -25,6 +45,9 @@
import type { PrismaClient } from "@/generated/prisma/client";
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import type { Locale } from "@/lib/i18n/config";
import { defaultLocale, locales } from "@/lib/i18n/config";
export const COACH_REMINDER_SWEEP_QUEUE = "coach-reminder-sweep";
// Daily at 05:20 Europe/Berlin — just after the 05:15 nudge tick so both
@@ -33,8 +56,13 @@ export const COACH_REMINDER_SWEEP_CRON = "20 5 * * *";
/** Bound the plan-review fan-out per tick so one sweep can't run unbounded. */
const PLAN_REVIEW_BATCH = 200;
/** Bound the surfacing fan-out per tick for the same reason. */
const SURFACE_BATCH = 500;
/** How many notes one surfacing message carries before the rest wait a tick. */
const MAX_NOTES_PER_MESSAGE = 6;
export interface CoachReminderSweepSummary {
/** Reminders whose moment arrived AND that reached the conversation. */
remindersDue: number;
planReviewsMinted: number;
errored: number;
@@ -42,12 +70,43 @@ export interface CoachReminderSweepSummary {
type SweepPrisma = Pick<
PrismaClient,
"coachReminder" | "coachPlan" | "$transaction"
| "coachReminder"
| "coachPlan"
| "coachConversation"
| "coachMessage"
| "user"
| "$transaction"
>;
function resolveLocale(locale: string | null | undefined): Locale {
return locales.includes(locale as Locale)
? (locale as Locale)
: defaultLocale;
}
/**
* Run one sweep tick. Idempotent: a reminder already `due` is not re-flipped,
* and a plan whose `reviewDate` was cleared on a prior tick is not re-minted.
* The surfacing message for one user's due reminders. Deterministic, localized,
* and built only from the user's own note text — the sweep composes, it never
* generates.
*/
export function buildReminderSurfaceMessage(
notes: readonly string[],
locale: string | null | undefined,
): { title: string; body: string } {
const t = getServerTranslator(resolveLocale(locale)).t;
const title = t("coachNudges.reminderSurfacedTitle");
const lead =
notes.length === 1
? t("coachNudges.reminderSurfacedLeadOne")
: t("coachNudges.reminderSurfacedLeadMany", { count: notes.length });
const body = [lead, ...notes.map((n) => `• ${n}`)].join("\n");
return { title, body };
}
/**
* Run one sweep tick. Idempotent: a reminder already `surfaced` is not
* re-surfaced, and a plan whose `reviewDate` was cleared on a prior tick is not
* re-minted.
*/
export async function runCoachReminderSweep(
prisma: SweepPrisma,
@@ -59,23 +118,10 @@ export async function runCoachReminderSweep(
errored: 0,
};
// ── 1. flip overdue active date-reminders → due ──────────────
const due = await prisma.coachReminder.updateMany({
where: {
deletedAt: null,
status: "active",
triggerKind: "date",
dueAt: { not: null, lte: now },
},
data: {
status: "due",
lastSurfacedAt: now,
surfaceCount: { increment: 1 },
},
});
summary.remindersDue = due.count;
// ── 2. mint plan-review reminders for passed reviewDates ──────
// ── 1. mint plan-review reminders for passed reviewDates ──────
//
// Minted `active` with `dueAt: now` so the surfacing pass below picks them
// up in this same tick. One path reaches the conversation, not two.
const plans = await prisma.coachPlan.findMany({
where: {
deletedAt: null,
@@ -120,10 +166,8 @@ export async function runCoachReminderSweep(
relatedPlanId: plan.id,
triggerKind: "date",
dueAt: now,
status: "due",
status: "active",
source: "extractor",
lastSurfacedAt: now,
surfaceCount: 1,
},
}),
prisma.coachPlan.update({
@@ -137,5 +181,94 @@ export async function runCoachReminderSweep(
}
}
// ── 2. surface everything whose moment has passed ─────────────
//
// `due` is included alongside `active` deliberately: rows an older build
// flipped to `due` never reached a conversation, and they are exactly the
// ones showing a badge with nothing behind it. Picking them up here converts
// a stuck indicator into the message it always promised.
const overdue = await prisma.coachReminder.findMany({
where: {
deletedAt: null,
status: { in: ["active", "due"] },
triggerKind: "date",
dueAt: { not: null, lte: now },
},
take: SURFACE_BATCH,
orderBy: { dueAt: "asc" },
select: { id: true, userId: true, noteEncrypted: true },
});
const byUser = new Map<string, typeof overdue>();
for (const row of overdue) {
const bucket = byUser.get(row.userId);
if (bucket) bucket.push(row);
else byUser.set(row.userId, [row]);
}
for (const [userId, rows] of byUser) {
try {
// Decrypt fault-isolated per row: one undecryptable note must not cost
// the user the reminders that sit beside it.
const usable: { id: string; note: string }[] = [];
for (const row of rows) {
try {
usable.push({
id: row.id,
note: decryptFromBytes(row.noteEncrypted),
});
} catch {
summary.errored += 1;
}
}
if (usable.length === 0) continue;
const batch = usable.slice(0, MAX_NOTES_PER_MESSAGE);
const user = await prisma.user.findUnique({
where: { id: userId },
select: { locale: true },
});
const { title, body } = buildReminderSurfaceMessage(
batch.map((r) => r.note),
user?.locale,
);
// Conversation + message + status flip in ONE transaction. A partial
// write is the failure this whole change exists to remove: a status
// without a message is a badge pointing at nothing, and a message
// without a status change re-surfaces the same note every night.
await prisma.$transaction(async (tx) => {
const conversation = await tx.coachConversation.create({
data: { userId, title: title.slice(0, 80) },
});
await tx.coachMessage.create({
data: {
conversationId: conversation.id,
role: "assistant",
encryptedContent: encryptToBytes(body),
metricSourceJson: null,
// Same tag the proactive nudge uses — this IS a proactive message,
// and the nudge cron's frequency gate reads the tag back.
providerType: "nudge",
promptVersion: null,
},
});
await tx.coachReminder.updateMany({
where: { id: { in: batch.map((r) => r.id) } },
data: {
status: "surfaced",
lastSurfacedAt: now,
surfaceCount: { increment: 1 },
},
});
});
summary.remindersDue += batch.length;
} catch {
// Leave the rows where they were: the next tick retries, and until then
// no badge claims a message that was never written.
summary.errored += 1;
}
}
return summary;
}
+6 -4
View File
@@ -822,10 +822,12 @@ export async function registerStatusQueues(
},
);
// v1.22 (M4) — daily Coach-reminder sweep. Single-flight; flipping an overdue
// reminder to `due` and minting plan-review reminders are both idempotent
// across re-fires (an already-due reminder is not re-flipped; a plan whose
// reviewDate was cleared is not re-minted). No provider call, no push.
// v1.22 (M4) — daily Coach-reminder sweep. Single-flight; surfacing an
// overdue reminder into the user's Coach thread and minting plan-review
// reminders are both idempotent across re-fires (an already-surfaced
// reminder is not re-written; a plan whose reviewDate was cleared is not
// re-minted). No provider call, no push — the message body is a
// deterministic localized template around the user's own note.
await createAndWork(
boss,
COACH_REMINDER_SWEEP_QUEUE,