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 b4de47a28..76b09170c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -464,6 +464,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/app/coach/page.tsx b/src/app/coach/page.tsx index d8c0d5402..9ffb5a694 100644 --- a/src/app/coach/page.tsx +++ b/src/app/coach/page.tsx @@ -63,15 +63,35 @@ export default function CoachPage() {
`'s shared `mx-auto max-w-screen-xl px-4 + // pt-6 pb-20 md:px-6` content frame, which boxed the chat into the + // same narrow column every dashboard card uses and left a tall dead + // band below the card (the `pb-20` FAB gutter, pointless here since + // the FAB hides on `/coach`). Break out of that frame: negative + // margins cancel the container's horizontal + top/bottom padding so + // the chat claims the full content width and the viewport height, + // then a light inner padding keeps it off the window edge. The card + // itself drops its rounded border on small screens (edge-to-edge, + // app-like) and keeps the contained card look from `sm+`. + // + // v1.18.1 (W-COACH-UI C1/C3) — the card formerly capped at + // `100dvh-13rem`, leaving wasted space below it; we now fill the + // reclaimed height. + className={ + // Cancel the AuthShell container's padding (`-mt-6` top, + // `-mb-20` bottom, `-mx-4 md:-mx-6` sides) so the surface is + // full-bleed, then re-pad lightly. The height is the full + // viewport minus the top bar (`4rem`) and the small gutters. + "-mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-4rem)] min-h-[32rem] flex-col md:-mx-6 " + + "px-2 pt-2 pb-3 sm:px-4 sm:pt-4 sm:pb-4" + } > +
} - /> + /> +
); } diff --git a/src/components/insights/coach-panel/coach-drawer-body.tsx b/src/components/insights/coach-panel/coach-drawer-body.tsx index f59461b02..1c677b86f 100644 --- a/src/components/insights/coach-panel/coach-drawer-body.tsx +++ b/src/components/insights/coach-panel/coach-drawer-body.tsx @@ -78,7 +78,18 @@ export function CoachDrawerBody({

{t("insights.coach.historyTitle")}

-
{historyRail}
+ {/* v1.18.6 (CCH-01) — the wrapping `overflow-y-auto` div here + was the broken left scrollbar: `` is itself a + `flex h-full flex-col` with its OWN `overflow-y-auto` list, + so this nested it inside a second scroll container. The + outer container scrolled the whole rail (search field + included) against an `h-full` child whose height resolved to + its content, leaving the inner list's `flex-1 min-h-0` + without a bounded track — the scrollbar rendered against an + indeterminate height and read as half-broken. Drop the outer + scroll: the rail keeps a fixed search header and its single + internal list-scroll is the only scroll region. */} +
{historyRail}
)} diff --git a/src/components/insights/coach-panel/message-thread.tsx b/src/components/insights/coach-panel/message-thread.tsx index 75a632f59..6dae240d0 100644 --- a/src/components/insights/coach-panel/message-thread.tsx +++ b/src/components/insights/coach-panel/message-thread.tsx @@ -363,6 +363,13 @@ export function MessageThread({ data-slot="coach-message-thread" className={cn( "flex h-full flex-col gap-4 overflow-y-auto px-4 py-4 sm:px-6", + // v1.18.6 (CCH-01) — on the now full-width page surface a thread + // stretched edge-to-edge sprawled the prose to an unreadable + // measure. Centre the content on a comfortable max width (Claude- + // like) via an `mx-auto` inner gutter; the scrollbar still rides + // the surface edge. The drawer surface is already narrow, so the + // cap only bites on the wide page surface. + "[&>*]:mx-auto [&>*]:w-full [&>*]:max-w-3xl", "scroll-smooth", )} > 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() { >