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

+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