mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
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);
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
Reference in new issue
Block a user