From f453f8a758cb204d2873e9a603c831956a3cea9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 18 Jun 2026 21:19:17 +0200 Subject: [PATCH 1/4] fix(coach): surface the proactive nudge in the conversation rail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The proactive Coach nudge dispatched a notification only — it never wrote a CoachMessage, so it was invisible under the conversation list and entirely silent for a user with no push channel configured. Record the nudge as a real conversation before dispatching: create a conversation and write the nudge body as the initial assistant message (encrypted at rest like every other Coach reply), then fire the existing notification + /coach deep link unchanged. A persistence failure is caught locally so it can never swallow the notification, and the tick summary tracks a separate persisted count alongside dispatched. --- .../ai/coach/__tests__/persistence.test.ts | 74 ++++++++++++++++++- src/lib/ai/coach/persistence.ts | 54 ++++++++++++++ src/lib/jobs/__tests__/coach-nudge.test.ts | 63 ++++++++++++++++ src/lib/jobs/coach-nudge.ts | 35 +++++++++ src/lib/jobs/reminder/register-status.ts | 1 + 5 files changed, 225 insertions(+), 2 deletions(-) diff --git a/src/lib/ai/coach/__tests__/persistence.test.ts b/src/lib/ai/coach/__tests__/persistence.test.ts index f506f5865..dbe6a1afc 100644 --- a/src/lib/ai/coach/__tests__/persistence.test.ts +++ b/src/lib/ai/coach/__tests__/persistence.test.ts @@ -1,6 +1,28 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; -import { summariseTitle } from "../persistence"; +// Mock the db + crypto boundaries so `recordProactiveNudge` can be +// exercised without a real Postgres / encryption key. The transaction +// runner simply invokes the callback with the stubbed `tx`. +const txCreate = { + coachConversation: { create: vi.fn() }, + coachMessage: { create: vi.fn() }, +}; +vi.mock("@/lib/db", () => ({ + prisma: { + $transaction: vi.fn( + async (cb: (tx: typeof txCreate) => Promise) => cb(txCreate), + ), + }, +})); +vi.mock("../bytes-codec", () => ({ + encryptToBytes: vi.fn((text: string) => + new TextEncoder().encode(`enc:${text}`), + ), + decryptFromBytes: vi.fn(), +})); + +import { recordProactiveNudge, summariseTitle } from "../persistence"; +import { encryptToBytes } from "../bytes-codec"; describe("summariseTitle", () => { it("returns the input unchanged when below 80 chars", () => { @@ -41,3 +63,51 @@ describe("summariseTitle", () => { expect(summariseTitle(" ")).toBe("New conversation"); }); }); + +describe("recordProactiveNudge", () => { + it("creates a conversation + an encrypted ASSISTANT message in one transaction", async () => { + const now = new Date("2026-06-18T05:15:00.000Z"); + txCreate.coachConversation.create.mockResolvedValue({ + id: "conv_1", + userId: "user_1", + title: "Time to weigh in", + createdAt: now, + updatedAt: now, + }); + txCreate.coachMessage.create.mockResolvedValue({ + id: "msg_1", + createdAt: now, + }); + + const out = await recordProactiveNudge({ + userId: "user_1", + title: "Time to weigh in", + body: "It has been a week — a quick weigh-in keeps the trend honest.", + }); + + expect(out).toEqual({ + conversationId: "conv_1", + messageId: "msg_1", + createdAt: now, + }); + + // Conversation owned by the user, title summarised. + expect(txCreate.coachConversation.create).toHaveBeenCalledWith({ + data: { userId: "user_1", title: "Time to weigh in" }, + }); + + // The body is encrypted at rest (Bytes), role is assistant, and the + // message hangs off the new conversation. No raw plaintext column. + const msgArg = txCreate.coachMessage.create.mock.calls[0][0]; + expect(msgArg.data.conversationId).toBe("conv_1"); + expect(msgArg.data.role).toBe("assistant"); + expect(msgArg.data.providerType).toBe("nudge"); + expect(encryptToBytes).toHaveBeenCalledWith( + "It has been a week — a quick weigh-in keeps the trend honest.", + ); + // The stored column is the ciphertext, never the plaintext body. + expect(new TextDecoder().decode(msgArg.data.encryptedContent)).toContain( + "enc:", + ); + }); +}); diff --git a/src/lib/ai/coach/persistence.ts b/src/lib/ai/coach/persistence.ts index 9d0c0440c..7e318ee93 100644 --- a/src/lib/ai/coach/persistence.ts +++ b/src/lib/ai/coach/persistence.ts @@ -188,6 +188,60 @@ export async function appendMessage( }; } +export interface RecordProactiveNudgeParams { + userId: string; + /** Conversation title (the nudge headline) — summarised to ≤80 chars. */ + title: string; + /** The nudge body, persisted as the initial ASSISTANT message. */ + body: string; + /** Trigger tag for the wide-event annotation on the caller side. */ + providerType?: string | null; +} + +/** + * v1.18.6 (CCH-02) — record a proactive Coach nudge as a real + * conversation so it shows up in the conversation rail regardless of + * which push channel (if any) the user configured. The proactive cron + * used to dispatch a notification ONLY; with no push channel the nudge + * was entirely invisible. + * + * Creates a fresh conversation and writes the nudge as the initial + * ASSISTANT message in one transaction so a partial write never leaves + * an empty thread in the rail. The body crosses the same + * `encryptToBytes` boundary as every other Coach message, so the nudge + * text is encrypted at rest like a normal reply. Returns the new + * conversation + message ids for the caller's annotation. + */ +export async function recordProactiveNudge( + params: RecordProactiveNudgeParams, +): Promise<{ conversationId: string; messageId: string; createdAt: Date }> { + return prisma.$transaction(async (tx) => { + const conversation = await tx.coachConversation.create({ + data: { + userId: params.userId, + title: summariseTitle(params.title), + }, + }); + const message = await tx.coachMessage.create({ + data: { + conversationId: conversation.id, + role: "assistant", + encryptedContent: encryptToBytes(params.body), + metricSourceJson: null, + providerType: params.providerType ?? "nudge", + promptVersion: null, + }, + }); + // The conversation's `createdAt` == `updatedAt` on creation, so the + // rail already orders it to the top; no extra `update` needed. + return { + conversationId: conversation.id, + messageId: message.id, + createdAt: message.createdAt, + }; + }); +} + /** * Fetch one conversation + every message, decrypting each body on * read. Returns null when the conversation does not exist OR when the diff --git a/src/lib/jobs/__tests__/coach-nudge.test.ts b/src/lib/jobs/__tests__/coach-nudge.test.ts index a417e359b..dfa3a5a23 100644 --- a/src/lib/jobs/__tests__/coach-nudge.test.ts +++ b/src/lib/jobs/__tests__/coach-nudge.test.ts @@ -714,4 +714,67 @@ describe("runCoachNudgeTick — gates and prefs", () => { "You wanted to keep an eye on: morning blood pressure.", ); }); + + it("persists the nudge as a conversation before dispatching (CCH-02)", async () => { + const order: string[] = []; + const recordNudge = vi.fn( + async (args: { userId: string; title: string; body: string }) => { + void args; + order.push("persist"); + return { + conversationId: "conv_1", + messageId: "msg_1", + createdAt: now, + }; + }, + ); + const dispatch = vi.fn(async () => { + order.push("dispatch"); + return { dispatched: true }; + }); + const prisma = prismaMock({ + users: [userRow(null)], + intakeRows: failingIntakes, + }); + const summary = await runCoachNudgeTick( + prisma as unknown as PrismaClient, + now, + { dispatch: dispatch as never, recordNudge: recordNudge as never }, + ); + expect(summary.dispatched).toBe(1); + expect(summary.persisted).toBe(1); + // The conversation must be written BEFORE the notification fires so + // the rail always carries the nudge even if the push channel is gone. + expect(order).toEqual(["persist", "dispatch"]); + const persistArg = recordNudge.mock.calls[0]?.[0] as unknown as { + userId: string; + title: string; + body: string; + }; + expect(persistArg.userId).toBe("user-1"); + // The persisted body is the same compliance nudge copy the + // notification carries — it lands as the initial assistant message. + expect(persistArg.body).toContain("Your intakes were patchy this week"); + expect(persistArg.title.length).toBeGreaterThan(0); + }); + + it("still dispatches + counts the nudge when persistence throws", async () => { + const recordNudge = vi.fn(async () => { + throw new Error("db down"); + }); + const dispatch = vi.fn(async () => ({ dispatched: true })); + const prisma = prismaMock({ + users: [userRow(null)], + intakeRows: failingIntakes, + }); + const summary = await runCoachNudgeTick( + prisma as unknown as PrismaClient, + now, + { dispatch: dispatch as never, recordNudge: recordNudge as never }, + ); + // A persistence failure must not swallow the notification. + expect(dispatch).toHaveBeenCalledTimes(1); + expect(summary.dispatched).toBe(1); + expect(summary.persisted).toBe(0); + }); }); diff --git a/src/lib/jobs/coach-nudge.ts b/src/lib/jobs/coach-nudge.ts index 8c5ecb03f..d1fe2724e 100644 --- a/src/lib/jobs/coach-nudge.ts +++ b/src/lib/jobs/coach-nudge.ts @@ -73,6 +73,7 @@ import { import { dispatchNotification } from "@/lib/notifications/dispatcher"; import { resolveCoachNudgePrefs } from "@/lib/validations/notification-prefs"; import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; +import { recordProactiveNudge } from "@/lib/ai/coach/persistence"; import { getServerTranslator } from "@/lib/i18n/server-translator"; import type { Locale } from "@/lib/i18n/config"; import { defaultLocale, locales } from "@/lib/i18n/config"; @@ -167,6 +168,13 @@ export const COACH_NUDGE_TRIGGER_GROUPS: Record< export interface CoachNudgeSummary { candidatesScanned: number; dispatched: number; + /** + * v1.18.6 (CCH-02) — nudges written into the conversation rail as an + * initial ASSISTANT message. Counted independently of `dispatched`: + * the conversation lands even when no push channel is configured, so + * `persisted` can exceed `dispatched`. + */ + persisted: number; skippedOptedOut: number; skippedNoProvider: number; skippedRecentNudge: number; @@ -695,13 +703,16 @@ export async function runCoachNudgeTick( now: Date, options: { dispatch?: typeof dispatchNotification; + recordNudge?: typeof recordProactiveNudge; } = {}, ): Promise { const dispatchImpl = options.dispatch ?? dispatchNotification; + const recordNudgeImpl = options.recordNudge ?? recordProactiveNudge; const summary: CoachNudgeSummary = { candidatesScanned: 0, dispatched: 0, + persisted: 0, skippedOptedOut: 0, skippedNoProvider: 0, skippedRecentNudge: 0, @@ -839,6 +850,30 @@ export async function runCoachNudgeTick( user.locale, coachFocus, ); + + // v1.18.6 (CCH-02) — persist the nudge as a real conversation + // BEFORE dispatching the notification. The proactive nudge used to + // be notification-only, so a user with no push channel never saw + // it anywhere; writing it as the initial ASSISTANT message means it + // always lands in the conversation rail (and clears the FAB unread + // dot once the user opens the Coach). A persistence failure must + // not swallow the notification, so it is caught locally and the + // dispatch still fires. + try { + await recordNudgeImpl({ + userId: user.id, + title, + body, + }); + summary.persisted += 1; + } catch (persistErr: unknown) { + const message = + persistErr instanceof Error ? persistErr.message : String(persistErr); + getEvent()?.addWarning( + `coach-nudge conversation persist failed for ${user.id}: ${message}`, + ); + } + const outcome = await dispatchImpl({ eventType: "COACH_NUDGE", userId: user.id, diff --git a/src/lib/jobs/reminder/register-status.ts b/src/lib/jobs/reminder/register-status.ts index ee7d1b900..0d5a7ab67 100644 --- a/src/lib/jobs/reminder/register-status.ts +++ b/src/lib/jobs/reminder/register-status.ts @@ -306,6 +306,7 @@ export async function registerStatusQueues( result: { candidates_scanned: summary.candidatesScanned, dispatched: summary.dispatched, + persisted: summary.persisted, skipped_opted_out: summary.skippedOptedOut, skipped_no_provider: summary.skippedNoProvider, skipped_recent_nudge: summary.skippedRecentNudge, From 3a3f88a80c6df0cea948821ca2ab79bab7005789 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 18 Jun 2026 21:19:27 +0200 Subject: [PATCH 2/4] feat(coach): mark an unread proactive nudge on the Coach launcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a discreet unread dot to the Coach FAB when the Coach has something the user has not opened yet, and clear it when they open the Coach. The signal is server-authoritative: a new coach_last_seen_at column on users records the last time the Coach was opened, and the launcher's status endpoint reports unread when the newest assistant message is newer than that stamp — so the dot works even with no push channel and clears across every device, web and iOS. Opening the Coach (FAB or the /coach page) stamps the column via a new /api/insights/coach/seen endpoint; the launcher keeps a local stamp only for an instant paint. The dot is a calm cyan, not an alarming red, per the card-status rule. --- .../0175_v1186_coach_last_seen/migration.sql | 15 ++++ prisma/schema.prisma | 12 +++ .../api/insights/coach/nudge-status/route.ts | 55 +++++++----- src/app/api/insights/coach/seen/route.ts | 33 +++++++ src/components/insights/layout-coach-fab.tsx | 89 +++++++++++++++---- src/lib/query-keys/coach.ts | 2 + 6 files changed, 165 insertions(+), 41 deletions(-) create mode 100644 prisma/migrations/0175_v1186_coach_last_seen/migration.sql create mode 100644 src/app/api/insights/coach/seen/route.ts diff --git a/prisma/migrations/0175_v1186_coach_last_seen/migration.sql b/prisma/migrations/0175_v1186_coach_last_seen/migration.sql new file mode 100644 index 000000000..c784fd21b --- /dev/null +++ b/prisma/migrations/0175_v1186_coach_last_seen/migration.sql @@ -0,0 +1,15 @@ +-- v1.18.6 (CCH-03) — Coach "last seen" stamp for the FAB unread dot. +-- +-- A single nullable timestamp on `users`. The proactive Coach nudge now +-- lands as an ASSISTANT message in the conversation rail (CCH-02); the +-- Coach FAB shows a discreet unread dot when the newest Coach message is +-- newer than this stamp, and clears it once the user opens the Coach +-- (the open writes the current time here via POST +-- /api/insights/coach/seen). +-- +-- NULL backfills every existing row to "never opened" — harmless: the +-- dot only paints when an actual newer Coach message exists, so a user +-- with no proactive nudge yet sees nothing. No data migration needed. + +ALTER TABLE "users" + ADD COLUMN "coach_last_seen_at" TIMESTAMP(3); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f87097c6e..bb3b65d1d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -453,6 +453,18 @@ model User { // `GET /api/auth/me` as a `modules: { : boolean }` map. modulePreferencesJson Json? @map("module_preferences_json") + // v1.18.6 (CCH-03) — "Coach last seen" stamp powering the unread dot + // on the Coach FAB. The proactive nudge now lands as an ASSISTANT + // message in the conversation rail (CCH-02); the FAB shows a discreet + // unread dot when the newest Coach message is newer than this stamp, + // and clears it when the user opens the Coach (drawer or page, which + // writes the current time here via `POST /api/insights/coach/seen`). + // Null = never opened the Coach, so any existing message reads as + // unread once. Server-authoritative so the signal is consistent across + // web + iOS; the FAB keeps a local mirror only as an instant-paint + // optimisation. + coachLastSeenAt DateTime? @map("coach_last_seen_at") + // v1.5.5 — self-hosted avatar storage. // // Replaces the Gravatar third-party leak (the old `getGravatarUrl` diff --git a/src/app/api/insights/coach/nudge-status/route.ts b/src/app/api/insights/coach/nudge-status/route.ts index a4aad8dd6..48d9d58c6 100644 --- a/src/app/api/insights/coach/nudge-status/route.ts +++ b/src/app/api/insights/coach/nudge-status/route.ts @@ -1,18 +1,26 @@ /** - * GET /api/insights/coach/nudge-status — has the proactive Coach nudge - * something the caller has not engaged with yet? + * GET /api/insights/coach/nudge-status — is there an unread Coach + * message the caller has not opened yet? * - * v1.16.1 — feeds the floating Coach bubble: the bubble renders only - * while an unseen Coach-initiated nudge exists, instead of being a - * permanent FAB. "Unseen" is derived, not stored: the latest successful - * `COACH_NUDGE` dispatch (the `push_attempts` ledger the nudge cron - * already anchors its frequency cap on) counts as read once the user - * has sent a Coach message after it. The client additionally remembers - * a local "seen" stamp so opening the chat without sending also clears - * the bubble on that device. + * v1.18.6 (CCH-03) — the proactive Coach nudge now lands as a real + * ASSISTANT message in the conversation rail (CCH-02), not as a + * notification-only dispatch. The unread signal moved with it: instead + * of anchoring on the `push_attempts` ledger (which is empty when no + * push channel is configured, so the nudge was invisible), the status + * compares the newest Coach assistant message against the + * server-authoritative `User.coachLastSeenAt` stamp. * - * No new table, no migration: both timestamps come from existing rows, - * scoped to the caller. + * `unread` is true when an assistant message exists that is newer than + * the last time the user opened the Coach (drawer or page, which writes + * `coachLastSeenAt` via `POST /api/insights/coach/seen`). A user who has + * never opened the Coach reads any existing nudge as unread exactly + * once. Server-authoritative so the signal is consistent across web + + * iOS; the FAB keeps a local mirror only as an instant-paint + * optimisation. + * + * `nudgedAt` carries the newest assistant-message timestamp so the FAB's + * local seen-stamp keys on a stable value (kept for the existing client + * contract). */ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiSuccess } from "@/lib/api-response"; @@ -23,23 +31,26 @@ export const GET = apiHandler(async () => { const { user } = await requireAuth(); await requireAssistantSurface("coach"); - const [lastNudge, lastUserMessage] = await Promise.all([ - prisma.pushAttempt.findFirst({ - where: { userId: user.id, eventType: "COACH_NUDGE", result: "ok" }, + // The newest assistant message across the caller's conversations — the + // proactive nudge writes one, and so does every normal Coach reply. We + // intentionally key on assistant (not user) messages: the unread dot + // means "the Coach said something", and the open-stamp clears it. + const [lastAssistant, seen] = await Promise.all([ + prisma.coachMessage.findFirst({ + where: { role: "assistant", conversation: { userId: user.id } }, orderBy: { createdAt: "desc" }, select: { createdAt: true }, }), - prisma.coachMessage.findFirst({ - where: { role: "user", conversation: { userId: user.id } }, - orderBy: { createdAt: "desc" }, - select: { createdAt: true }, + prisma.user.findUnique({ + where: { id: user.id }, + select: { coachLastSeenAt: true }, }), ]); - const nudgedAt = lastNudge?.createdAt ?? null; + const nudgedAt = lastAssistant?.createdAt ?? null; + const lastSeenAt = seen?.coachLastSeenAt ?? null; const unread = - nudgedAt !== null && - (lastUserMessage === null || lastUserMessage.createdAt < nudgedAt); + nudgedAt !== null && (lastSeenAt === null || lastSeenAt < nudgedAt); return apiSuccess({ nudgedAt: nudgedAt ? nudgedAt.toISOString() : null, diff --git a/src/app/api/insights/coach/seen/route.ts b/src/app/api/insights/coach/seen/route.ts new file mode 100644 index 000000000..603edafb7 --- /dev/null +++ b/src/app/api/insights/coach/seen/route.ts @@ -0,0 +1,33 @@ +/** + * POST /api/insights/coach/seen — mark every Coach message as read. + * + * v1.18.6 (CCH-03) — opening the Coach (drawer or full page) clears the + * unread dot on the FAB. The open stamps `User.coachLastSeenAt = now`; + * the FAB's `GET /api/insights/coach/nudge-status` then reads no + * assistant message newer than the stamp and drops the dot. Server- + * authoritative so the cleared state follows the user across web + iOS, + * not just the device that opened the Coach. + * + * No request body — the timestamp is server-minted from `now()` so a + * client can never backdate the stamp to suppress a future nudge. + */ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess } from "@/lib/api-response"; +import { prisma } from "@/lib/db"; +import { requireAssistantSurface } from "@/lib/feature-flags"; + +export const POST = apiHandler(async () => { + const { user } = await requireAuth(); + await requireAssistantSurface("coach"); + + const seenAt = new Date(); + await prisma.user.update({ + where: { id: user.id }, + data: { coachLastSeenAt: seenAt }, + }); + + return apiSuccess({ seenAt: seenAt.toISOString() }); +}); + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; diff --git a/src/components/insights/layout-coach-fab.tsx b/src/components/insights/layout-coach-fab.tsx index 0be23a4c4..ac0c877e6 100644 --- a/src/components/insights/layout-coach-fab.tsx +++ b/src/components/insights/layout-coach-fab.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { usePathname } from "next/navigation"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Sparkles } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -13,18 +13,26 @@ import { useFeatureFlags } from "@/hooks/use-feature-flags"; import { useDisableCoach } from "@/hooks/use-disable-coach"; import { queryKeys } from "@/lib/query-keys"; -import { apiGet } from "@/lib/api/api-fetch"; +import { apiGet, apiPost } from "@/lib/api/api-fetch"; /** * v1.16.8 — the Coach FAB is a permanent launcher again, now on EVERY * authenticated page (mounted once in ``, no longer scoped * to `/insights/**`). The v1.16.1 nudge-only bubble folded into it: the - * FAB always renders, and an unseen proactive `COACH_NUDGE` (surfaced + * FAB always renders, and an unread proactive Coach message (surfaced * by `/api/insights/coach/nudge-status`) paints a small unread dot on - * its corner instead of toggling the whole button. The dot clears once - * the nudge counts as read — the user sent a Coach message after the - * nudge (server-derived), or opened the Coach on this device (local - * seen stamp keyed by the nudge timestamp). + * its corner instead of toggling the whole button. + * + * v1.18.6 (CCH-02/CCH-03) — the proactive nudge now lands as a real + * ASSISTANT message in the conversation rail, not a notification-only + * dispatch, so the unread signal moved onto the server-authoritative + * `User.coachLastSeenAt` stamp: `status.unread` is true while a Coach + * message is newer than the last time the user opened the Coach. Opening + * the Coach (this FAB, or the `/coach` page) fires + * `POST /api/insights/coach/seen`, which stamps the server and clears + * the dot everywhere (web + iOS). A local "seen" stamp is kept only as + * an instant-paint optimisation so the dot disappears the moment the + * user taps, before the mutation round-trips. * * The FAB hides inside the Coach view itself (`/coach`) — a * launcher pointing at the page the user is already on is noise. @@ -44,9 +52,12 @@ export interface CoachNudgeStatus { } /** - * Pure unread derivation — server signal AND-ed with the local - * seen-stamp. Exported so the unit test pins the contract without a - * QueryClient round-trip. + * Pure unread derivation — the server signal is authoritative + * (`status.unread` already compares the newest Coach message against the + * persisted `coachLastSeenAt`). The local seen-stamp only suppresses the + * dot for an instant after the user taps, before the mark-seen mutation + * + status refetch land. Exported so the unit test pins the contract + * without a QueryClient round-trip. */ export function isNudgeUnread( status: CoachNudgeStatus | undefined, @@ -77,6 +88,7 @@ export function LayoutCoachFab() { const coachAvailable = !!launch && flags.coach && !disableCoach; const onCoachPage = pathname?.startsWith("/coach") ?? false; + const queryClient = useQueryClient(); const { data: status } = useQuery({ queryKey: queryKeys.coachNudgeStatus(), @@ -87,6 +99,22 @@ export function LayoutCoachFab() { staleTime: 5 * 60 * 1000, }); + // v1.18.6 (CCH-03) — stamp `coachLastSeenAt` server-side when the user + // opens the Coach so the dot clears across every device. Fire-and- + // forget from the UI's perspective (the local seen-stamp already + // suppressed the dot); on success the status query refetches so the + // server-authoritative `unread` re-resolves to false. + const markSeen = useMutation({ + mutationKey: queryKeys.coachMarkSeen(), + mutationFn: async () => + apiPost<{ seenAt: string }>("/api/insights/coach/seen", {}), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: queryKeys.coachNudgeStatus(), + }); + }, + }); + const nudgedAt = status?.nudgedAt ?? null; const unread = isNudgeUnread(status, seenStamp); @@ -107,16 +135,30 @@ export function LayoutCoachFab() { prevUnreadRef.current = unread; }, [unread, t]); - // Visiting the Coach page itself counts as reading the nudge on this - // device — persist the stamp so the dot stays gone after leaving. + // Visiting the Coach page itself counts as reading the nudge — persist + // the local instant-paint stamp AND stamp the server so the dot stays + // gone after leaving, on every device. Fired once per page entry: the + // ref guards against the effect re-running on unrelated re-renders. + const seenStampedRef = useRef(false); useEffect(() => { - if (!onCoachPage || nudgedAt === null) return; - try { - window.localStorage.setItem(NUDGE_SEEN_STORAGE_KEY, nudgedAt); - } catch { - // Storage unavailable (private mode) — the server-side read - // signal still clears the dot once the user sends a message. + if (!onCoachPage) { + seenStampedRef.current = false; + return; } + if (seenStampedRef.current) return; + seenStampedRef.current = true; + if (nudgedAt !== null) { + try { + window.localStorage.setItem(NUDGE_SEEN_STORAGE_KEY, nudgedAt); + } catch { + // Storage unavailable (private mode) — the server stamp below + // still clears the dot once it round-trips. + } + } + markSeen.mutate(); + // `markSeen` is a stable mutation object; excluding it keeps the + // effect from re-firing on its internal state changes. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [onCoachPage, nudgedAt]); if (!coachAvailable) return null; @@ -136,6 +178,10 @@ export function LayoutCoachFab() { // Best effort — see above. } } + // v1.18.6 (CCH-03) — stamp the server so the dot clears on every + // device, not just this one. The local stamp above already hid the + // dot for an instant paint; the mutation makes it durable. + if (unread) markSeen.mutate(); // v1.16.11 — open the side drawer in place (the launch context the // layout mount consumes) instead of navigating to the chat page; // the conversation arrives without losing the page underneath. @@ -194,10 +240,15 @@ export function LayoutCoachFab() { >