mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +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
@@ -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";
|
||||
Reference in new issue
Block a user