fix(coach): make the launcher's dot mean a message you can read

The Coach button showed its unread dot over an empty conversation, and
opening the Coach neither showed anything nor cleared the dot.

Two paths fed one indicator. The nightly sweep flipped a "remind me"
reminder from active to due and stopped there — no conversation, no
message, and `surfaced` had no writer at all. The launcher lit its dot off
a second query that asked only whether anything was due. So the dot
promised content that was never written, and because opening the Coach
clears the message-based signal and not the reminder one, the only way out
was to resolve the reminder in Settings, which nothing on the badge
suggested. A reminder minted for a plan review put a user who had never
typed in the Coach in exactly that state.

The sweep now writes the reminder into the user's Coach thread as a real
assistant message and marks it surfaced in the same transaction: no
message, no status change, and nothing to point at. The badge derives from
that message like every other proactive one, so opening the Coach shows the
note and clears the dot. The launcher's second query is gone.

Reminders an older build left stuck on due are picked up by the same pass,
so an indicator that has been sitting on an empty conversation resolves
into the message it always implied.

No provider call and no push: the body is a deterministic localized
template around the user's own note text, six languages.

Proven end to end against Postgres — the row exists, the badge reads that
row, opening clears it, a second tick writes nothing, and a failed write
leaves the reminder untouched with the badge dark. All five fail against
the previous sweep.

The second indicator in the report, under the bottom bar's overflow entry,
is the active-route marker for that group and not a badge.

Refs #715
This commit is contained in:
Marc-André Bombeck committed 2026-08-07 16:29:58 +02:00
1 parent eb5c8a91fb
commit 82490870e5
11 files changed
+594 -112

No files matched your search

+3
View File
@@ -7417,6 +7417,9 @@
"sleepDebtBody": "Einige deiner Nächte waren diese Woche recht kurz. Wenn du willst, suchen wir gemeinsam nach einem Muster.",
"measurementGapBody": "Dein Protokoll ist seit etwa einer Woche still. Wann immer es dir passt, hilft ein kurzer Austausch beim Wiedereinstieg.",
"focusNote": "Das passt auch zu dem, was du selbst im Blick behalten möchtest.",
"reminderSurfacedTitle": "Deine Erinnerung",
"reminderSurfacedLeadOne": "Du wolltest heute daran erinnert werden:",
"reminderSurfacedLeadMany": "Du wolltest heute an {count} Dinge erinnert werden:",
"questionConditions": "Hast du chronische Erkrankungen, die der Coach kennen sollte?",
"questionFocus": "Gibt es etwas Bestimmtes, worauf der Coach achten soll?",
"questionAllergies": "Hast du Allergien oder Unverträglichkeiten?",
+3
View File
@@ -7417,6 +7417,9 @@
"sleepDebtBody": "Several of your nights came up short this week. If you want, we can look for a pattern together.",
"measurementGapBody": "Your log has been quiet for about a week. Whenever it suits you, a quick check-in helps you pick things back up.",
"focusNote": "It also ties into what you said you wanted to keep an eye on.",
"reminderSurfacedTitle": "The reminder you asked for",
"reminderSurfacedLeadOne": "You asked me to bring this back to you today:",
"reminderSurfacedLeadMany": "You asked me to bring {count} things back to you today:",
"questionConditions": "Do you have any chronic conditions the Coach should know about?",
"questionFocus": "Is there anything specific the Coach should pay attention to?",
"questionAllergies": "Do you have any allergies or intolerances?",
+3
View File
@@ -7417,6 +7417,9 @@
"sleepDebtBody": "Varias de tus noches se quedaron cortas esta semana. Si te apetece, buscamos un patrón juntos.",
"measurementGapBody": "Tu registro lleva una semana en silencio. Cuando te venga bien, una breve puesta al día ayuda a retomarlo.",
"focusNote": "También tiene que ver con eso que querías vigilar.",
"reminderSurfacedTitle": "Tu recordatorio",
"reminderSurfacedLeadOne": "Me pediste que hoy te recordara esto:",
"reminderSurfacedLeadMany": "Me pediste que hoy te recordara {count} cosas:",
"questionConditions": "¿Tienes enfermedades crónicas que el Coach deba conocer?",
"questionFocus": "¿Hay algo concreto en lo que el Coach deba fijarse?",
"questionAllergies": "¿Tienes alergias o intolerancias?",
+3
View File
@@ -7417,6 +7417,9 @@
"sleepDebtBody": "Plusieurs de vos nuits ont été un peu courtes cette semaine. Si vous voulez, on cherche une régularité ensemble.",
"measurementGapBody": "Votre journal est silencieux depuis environ une semaine. Quand cela vous convient, un petit point aide à reprendre.",
"focusNote": "Cela rejoint aussi ce que vous vouliez garder à l'œil.",
"reminderSurfacedTitle": "Ton rappel",
"reminderSurfacedLeadOne": "Tu m'as demandé de te rappeler ceci aujourd'hui :",
"reminderSurfacedLeadMany": "Tu m'as demandé de te rappeler {count} choses aujourd'hui :",
"questionConditions": "Avez-vous des maladies chroniques que le Coach devrait connaître ?",
"questionFocus": "Y a-t-il quelque chose de précis auquel le Coach devrait veiller ?",
"questionAllergies": "Avez-vous des allergies ou des intolérances ?",
+3
View File
@@ -7417,6 +7417,9 @@
"sleepDebtBody": "Diverse delle tue notti sono state un po' corte questa settimana. Se ti va, cerchiamo insieme uno schema.",
"measurementGapBody": "Il tuo diario è in silenzio da circa una settimana. Quando ti è comodo, due parole aiutano a riprendere.",
"focusNote": "Si lega anche a ciò che volevi tenere d'occhio.",
"reminderSurfacedTitle": "Il tuo promemoria",
"reminderSurfacedLeadOne": "Mi avevi chiesto di ricordarti questo oggi:",
"reminderSurfacedLeadMany": "Mi avevi chiesto di ricordarti {count} cose oggi:",
"questionConditions": "Hai malattie croniche che il Coach dovrebbe conoscere?",
"questionFocus": "C'è qualcosa di specifico a cui il Coach dovrebbe fare attenzione?",
"questionAllergies": "Hai allergie o intolleranze?",
+3
View File
@@ -7417,6 +7417,9 @@
"sleepDebtBody": "Kilka Twoich nocy było w tym tygodniu krótkich. Jeśli masz ochotę, poszukajmy razem wzorca.",
"measurementGapBody": "Twój dziennik milczy od około tygodnia. Kiedy Ci pasuje, krótka rozmowa pomoże wrócić do rutyny.",
"focusNote": "Wiąże się to też z tym, na co chciałeś/chciałaś zwracać uwagę.",
"reminderSurfacedTitle": "Twoje przypomnienie",
"reminderSurfacedLeadOne": "Prosiłeś, żeby przypomnieć ci o tym dzisiaj:",
"reminderSurfacedLeadMany": "Prosiłeś, żeby przypomnieć ci dzisiaj o {count} sprawach:",
"questionConditions": "Czy masz choroby przewlekłe, o których Coach powinien wiedzieć?",
"questionFocus": "Czy jest coś konkretnego, na co Coach powinien zwracać uwagę?",
"questionAllergies": "Czy masz alergie lub nietolerancje?",
+12 -19
View File
@@ -122,27 +122,20 @@ export function LayoutCoachFab() {
},
});
// v1.22 (M4) — surface a due/surfaced Coach reminder on the FAB. Additive:
// the dot also lights when the daily sweep has flipped a "remind me" reminder
// to due, independent of the nudge seen-stamp (resolving the reminder in the
// ledger clears it). Same cheap, gated, stale-while-revalidate read shape.
const { data: dueReminders } = useQuery({
queryKey: queryKeys.coachReminders("due,surfaced"),
queryFn: async () => {
const data = await apiGet<{ reminders?: unknown[] } | undefined>(
"/api/coach/reminders?status=due,surfaced",
);
return data?.reminders ?? [];
},
enabled: coachAvailable && !onCoachPage,
staleTime: 5 * 60 * 1000,
});
const hasDueReminders = (dueReminders?.length ?? 0) > 0;
const nudgedAt = status?.nudgedAt ?? null;
// ONE source for the dot: an assistant message the user has not opened yet.
//
// v1.22 added a second arm — a separate "any due reminder?" query — and that
// is the arm that produced a badge over an empty Coach. The sweep flipped a
// reminder to `due` without writing anything into a conversation, so the dot
// promised a message that did not exist; opening the Coach showed the empty
// state and did not clear the dot either, because only resolving the reminder
// in Settings could. The sweep now writes the reminder into the thread as a
// real assistant message (`coach-reminder-sweep.ts`), which the nudge status
// already reports — so the reminder still lights the dot, through the message
// it actually wrote, and opening the Coach clears it like any other.
const unread = isNudgeUnread(status, seenStamp);
// The visual dot lights for either an unread nudge OR a due reminder.
const showDot = unread || hasDueReminders;
const showDot = unread;
// The unread dot is visual-only (`aria-hidden`) and the swapped
// `aria-label` is not announced on mutation — a screen-reader user
@@ -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,
@@ -0,0 +1,239 @@
/**
* The Coach badge and the Coach conversation, over the real assembly.
*
* The report: the FAB shows its unread dot, you open the Coach, the
* conversation is empty, and the dot is still there afterwards. Two separate
* paths were feeding one indicator — a "is any reminder due?" query lit the
* dot, while the nightly sweep that made the reminder due wrote nothing into a
* conversation and had no way to be cleared by opening one.
*
* The unit suite can prove the sweep CALLS the right prisma methods; it cannot
* prove a row exists, that the badge derives from that row, or that opening
* the Coach clears it. Those three claims are the defect, so they are asserted
* here against Postgres, through the same functions the cron and the FAB use:
* `runCoachReminderSweep` writes, `readCoachNudgeStatus` reads, and the seen
* stamp in between is the one `POST /api/insights/coach/seen` writes.
*
* The note text is round-tripped through the real AES codec rather than a
* mock, so an encoding mistake between the sweep and the conversation reader
* fails here instead of shipping as an unreadable reminder.
*/
import { beforeEach, describe, expect, it } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
import { runCoachReminderSweep } from "@/lib/jobs/coach-reminder-sweep";
import { readCoachNudgeStatus } from "@/lib/ai/coach/nudge-status";
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
const NOW = new Date("2026-08-07T05:20:00.000Z");
const YESTERDAY = new Date("2026-08-06T09:00:00.000Z");
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
});
async function seedUser(username: string, locale = "en") {
return getPrismaClient().user.create({
data: {
username,
email: `${username}@example.test`,
timezone: "UTC",
locale,
},
});
}
describe("a due Coach reminder reaches the conversation the badge points at", () => {
it("writes the note as an assistant message, and that message is what makes the badge unread", async () => {
const prisma = getPrismaClient();
const user = await seedUser("reminder-surfacing");
await prisma.coachReminder.create({
data: {
userId: user.id,
noteEncrypted: encryptToBytes("ask me about my sleep again"),
triggerKind: "date",
dueAt: YESTERDAY,
status: "active",
source: "user",
},
});
// Before the sweep there is nothing to be unread ABOUT.
const before = await readCoachNudgeStatus(user.id);
expect(before.unread).toBe(false);
expect(before.conversationId).toBeNull();
const summary = await runCoachReminderSweep(prisma, NOW);
expect(summary.remindersDue).toBe(1);
expect(summary.errored).toBe(0);
// BOTH ENDS. The row exists…
const messages = await prisma.coachMessage.findMany({
where: { conversation: { userId: user.id } },
});
expect(messages).toHaveLength(1);
expect(messages[0].role).toBe("assistant");
expect(decryptFromBytes(messages[0].encryptedContent)).toContain(
"ask me about my sleep again",
);
// …and the badge is derived from THAT row, not from a parallel query.
const after = await readCoachNudgeStatus(user.id);
expect(after.unread).toBe(true);
expect(after.conversationId).toBe(messages[0].conversationId);
// The reminder is marked surfaced, so it stops being pending work.
const reminder = await prisma.coachReminder.findFirstOrThrow({
where: { userId: user.id },
});
expect(reminder.status).toBe("surfaced");
expect(reminder.surfaceCount).toBe(1);
});
it("opening the Coach clears the badge", async () => {
const prisma = getPrismaClient();
const user = await seedUser("reminder-seen");
await prisma.coachReminder.create({
data: {
userId: user.id,
noteEncrypted: encryptToBytes("check the blood pressure trend"),
triggerKind: "date",
dueAt: YESTERDAY,
status: "active",
source: "user",
},
});
await runCoachReminderSweep(prisma, NOW);
expect((await readCoachNudgeStatus(user.id)).unread).toBe(true);
// What `POST /api/insights/coach/seen` writes when the Coach is opened.
// Anchored on the MESSAGE's own `createdAt`: the sweep takes its `now` for
// the due-date arithmetic, but the row is stamped by the database, and the
// route stamps the seen time from the same clock the row came from.
const written = await prisma.coachMessage.findFirstOrThrow({
where: { conversation: { userId: user.id } },
});
await prisma.user.update({
where: { id: user.id },
data: { coachLastSeenAt: new Date(written.createdAt.getTime() + 1_000) },
});
expect((await readCoachNudgeStatus(user.id)).unread).toBe(false);
});
it("does not surface the same reminder twice", async () => {
const prisma = getPrismaClient();
const user = await seedUser("reminder-idempotent");
await prisma.coachReminder.create({
data: {
userId: user.id,
noteEncrypted: encryptToBytes("weekly review"),
triggerKind: "date",
dueAt: YESTERDAY,
status: "active",
source: "user",
},
});
await runCoachReminderSweep(prisma, NOW);
const second = await runCoachReminderSweep(
prisma,
new Date(NOW.getTime() + 86_400_000),
);
expect(second.remindersDue).toBe(0);
expect(
await prisma.coachMessage.count({
where: { conversation: { userId: user.id } },
}),
).toBe(1);
});
it("takes a reminder an older build left stuck on `due` and gives it the message it promised", async () => {
const prisma = getPrismaClient();
const user = await seedUser("reminder-legacy");
await prisma.coachReminder.create({
data: {
userId: user.id,
noteEncrypted: encryptToBytes("the note nobody ever saw"),
triggerKind: "date",
dueAt: new Date("2026-05-01T09:00:00.000Z"),
status: "due",
lastSurfacedAt: new Date("2026-05-01T05:20:00.000Z"),
surfaceCount: 1,
source: "user",
},
});
// This is the reported state exactly: something the UI treated as
// outstanding, with an empty conversation behind it.
expect(
await prisma.coachMessage.count({
where: { conversation: { userId: user.id } },
}),
).toBe(0);
await runCoachReminderSweep(prisma, NOW);
const messages = await prisma.coachMessage.findMany({
where: { conversation: { userId: user.id } },
});
expect(messages).toHaveLength(1);
expect(decryptFromBytes(messages[0].encryptedContent)).toContain(
"the note nobody ever saw",
);
expect((await readCoachNudgeStatus(user.id)).unread).toBe(true);
});
it("keeps a failed write from leaving a badge with nothing behind it", async () => {
const prisma = getPrismaClient();
const user = await seedUser("reminder-partial");
const reminder = await prisma.coachReminder.create({
data: {
userId: user.id,
noteEncrypted: encryptToBytes("intact note"),
triggerKind: "date",
dueAt: YESTERDAY,
status: "active",
source: "user",
},
});
// Fail the transaction the way a real outage would — inside the write, so
// the conversation, the message and the status flip are all in flight.
const failing = new Proxy(prisma, {
get(target, prop, receiver) {
if (prop === "$transaction") {
return (arg: unknown) => {
if (typeof arg === "function") {
return Promise.reject(new Error("connection reset"));
}
const passthrough = Reflect.get(target, prop, receiver) as (
ops: unknown,
) => Promise<unknown>;
return passthrough.call(target, arg);
};
}
return Reflect.get(target, prop, receiver);
},
});
const summary = await runCoachReminderSweep(failing as typeof prisma, NOW);
expect(summary.remindersDue).toBe(0);
expect(summary.errored).toBe(1);
// Nothing written, nothing marked — and the badge stays dark, which is the
// honest state: there is no message to read.
expect(
await prisma.coachMessage.count({
where: { conversation: { userId: user.id } },
}),
).toBe(0);
const untouched = await prisma.coachReminder.findFirstOrThrow({
where: { id: reminder.id },
});
expect(untouched.status).toBe("active");
expect((await readCoachNudgeStatus(user.id)).unread).toBe(false);
});
});