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] 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() { >