feat(coach): mark an unread proactive nudge on the Coach launcher

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-18 21:19:27 +02:00
1 parent f453f8a758
commit 3a3f88a80c
6 files changed
+165 -41

No files matched your search

@@ -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);
+12
View File
@@ -453,6 +453,18 @@ model User {
// `GET /api/auth/me` as a `modules: { <key>: 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`
@@ -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,
+33
View File
@@ -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";
+70 -19
View File
@@ -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 `<AuthShell>`, 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() {
>
<Sparkles className="size-6" aria-hidden="true" />
{unread ? (
// v1.18.6 (CCH-03) — discreet "the Coach said something" dot.
// Deliberately NOT an alarming red (the medication-card rule:
// status via calm signal, never an alarm tint): a small cyan
// dot ringed against the FAB background reads as informative,
// not urgent.
<span
data-slot="coach-fab-unread"
aria-hidden="true"
className="border-background bg-dracula-red absolute top-0.5 right-0.5 size-3 rounded-full border-2"
className="border-background bg-dracula-cyan absolute top-0.5 right-0.5 size-3 rounded-full border-2"
/>
) : null}
</Button>
+2
View File
@@ -11,4 +11,6 @@ export const coachKeys = {
coachAboutMeQuestions: () => ["coach-about-me", "questions"] as const,
/** v1.16.1 — unseen proactive-nudge state for the floating Coach bubble. */
coachNudgeStatus: () => ["coach-nudge-status"] as const,
/** v1.18.6 (CCH-03) — mark-Coach-seen mutation (clears the FAB unread dot). */
coachMarkSeen: () => ["coach-mark-seen"] as const,
};