From 9ef79b23579e7f302f89d58253ddeb842fa346c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 13:31:17 +0200 Subject: [PATCH] fix(coach): deep-link the proactive nudge to its conversation The bottom-right launcher opened a blank new chat on an unread proactive message instead of the thread the Coach wrote. Return the owning conversation id from the nudge-status endpoint, have the launcher open /coach?c= on the unread path, and stamp the same deep link on the push metadata so the notification lands in the exact conversation rather than relying on a most-recent heuristic. --- docs/api/openapi.yaml | 7 ++++ .../api/insights/coach/nudge-status/route.ts | 10 +++++- .../__tests__/layout-coach-fab.test.tsx | 32 ++++++++++++++++--- src/components/insights/layout-coach-fab.tsx | 20 +++++++++++- src/lib/jobs/coach-nudge.ts | 15 +++++++-- src/lib/openapi/routes/coach.ts | 6 ++++ 6 files changed, 80 insertions(+), 10 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 1138e83df..0005a232d 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -16654,9 +16654,16 @@ components: unread: type: boolean description: True when that message is newer than the last time the caller opened the Coach. + conversationId: + anyOf: + - type: string + - type: "null" + description: Conversation holding the newest assistant message; null when none exists. The client deep-links into it + (`/coach?c=`) on the unread path. required: - nudgedAt - unread + - conversationId additionalProperties: false error: type: "null" diff --git a/src/app/api/insights/coach/nudge-status/route.ts b/src/app/api/insights/coach/nudge-status/route.ts index 48d9d58c6..13653ac7d 100644 --- a/src/app/api/insights/coach/nudge-status/route.ts +++ b/src/app/api/insights/coach/nudge-status/route.ts @@ -39,7 +39,11 @@ export const GET = apiHandler(async () => { prisma.coachMessage.findFirst({ where: { role: "assistant", conversation: { userId: user.id } }, orderBy: { createdAt: "desc" }, - select: { createdAt: true }, + // v1.22 (W5) — also return the owning conversation id so the FAB can + // deep-link straight into the proactive thread (`/coach?c=`) + // instead of opening a blank new chat and relying on a most-recent + // heuristic to find it. + select: { createdAt: true, conversationId: true }, }), prisma.user.findUnique({ where: { id: user.id }, @@ -55,6 +59,10 @@ export const GET = apiHandler(async () => { return apiSuccess({ nudgedAt: nudgedAt ? nudgedAt.toISOString() : null, unread, + // The conversation holding the newest assistant message. Present + // whenever a Coach reply exists; the FAB only consumes it on the + // unread path. + conversationId: lastAssistant?.conversationId ?? null, }); }); diff --git a/src/components/insights/__tests__/layout-coach-fab.test.tsx b/src/components/insights/__tests__/layout-coach-fab.test.tsx index 767dca039..5ad010ae3 100644 --- a/src/components/insights/__tests__/layout-coach-fab.test.tsx +++ b/src/components/insights/__tests__/layout-coach-fab.test.tsx @@ -171,20 +171,42 @@ describe("isNudgeUnread", () => { it("is false without status or without a nudge", () => { expect(isNudgeUnread(undefined, null)).toBe(false); - expect(isNudgeUnread({ nudgedAt: null, unread: false }, null)).toBe(false); + expect( + isNudgeUnread( + { nudgedAt: null, unread: false, conversationId: null }, + null, + ), + ).toBe(false); }); it("is true for a server-unread nudge the device has not seen", () => { - expect(isNudgeUnread({ nudgedAt, unread: true }, null)).toBe(true); - expect(isNudgeUnread({ nudgedAt, unread: true }, "2026-01-01")).toBe(true); + expect( + isNudgeUnread({ nudgedAt, unread: true, conversationId: "conv-1" }, null), + ).toBe(true); + expect( + isNudgeUnread( + { nudgedAt, unread: true, conversationId: "conv-1" }, + "2026-01-01", + ), + ).toBe(true); }); it("is false once the server counts the nudge as read", () => { // The user sent a Coach message after the nudge. - expect(isNudgeUnread({ nudgedAt, unread: false }, null)).toBe(false); + expect( + isNudgeUnread( + { nudgedAt, unread: false, conversationId: "conv-1" }, + null, + ), + ).toBe(false); }); it("is false once this device stored the matching seen stamp", () => { - expect(isNudgeUnread({ nudgedAt, unread: true }, nudgedAt)).toBe(false); + expect( + isNudgeUnread( + { nudgedAt, unread: true, conversationId: "conv-1" }, + nudgedAt, + ), + ).toBe(false); }); }); diff --git a/src/components/insights/layout-coach-fab.tsx b/src/components/insights/layout-coach-fab.tsx index e59d77802..29412062e 100644 --- a/src/components/insights/layout-coach-fab.tsx +++ b/src/components/insights/layout-coach-fab.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import { usePathname } from "next/navigation"; +import { usePathname, useRouter } from "next/navigation"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Sparkles } from "lucide-react"; @@ -49,6 +49,12 @@ const NUDGE_SEEN_STORAGE_KEY = "healthlog-coach-nudge-seen"; export interface CoachNudgeStatus { nudgedAt: string | null; unread: boolean; + /** + * v1.22 (W5) — the conversation holding the newest assistant message, so + * the FAB can deep-link into the proactive thread on the unread path + * instead of opening a blank new chat. Null when no Coach reply exists. + */ + conversationId: string | null; } /** @@ -70,6 +76,7 @@ export function isNudgeUnread( export function LayoutCoachFab() { const { t } = useTranslations(); const pathname = usePathname(); + const router = useRouter(); const launch = useCoachLaunch(); const flags = useFeatureFlags(); const disableCoach = useDisableCoach(); @@ -182,6 +189,17 @@ export function LayoutCoachFab() { // 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.22 (W5) — when the Coach has written an UNREAD proactive message, + // deep-link straight into that conversation (`/coach?c=`) so the + // user lands on what the Coach said, rather than opening a blank new + // chat in the drawer. The full page honours `?c=` → + // `initialConversationId`. Fall back to the in-place drawer for the + // ordinary launcher case (no unread, or no conversation id yet). + const conversationId = status?.conversationId ?? null; + if (unread && conversationId) { + router.push(`/coach?c=${conversationId}`); + return; + } // 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. diff --git a/src/lib/jobs/coach-nudge.ts b/src/lib/jobs/coach-nudge.ts index 24c2d948f..55c907164 100644 --- a/src/lib/jobs/coach-nudge.ts +++ b/src/lib/jobs/coach-nudge.ts @@ -877,12 +877,17 @@ export async function runCoachNudgeTick( // dot once the user opens the Coach). A persistence failure must // not swallow the notification, so it is caught locally and the // dispatch still fires. + let nudgeConversationId: string | null = null; try { - await recordNudgeImpl({ + const recorded = await recordNudgeImpl({ userId: user.id, title, body, }); + // v1.22 (W5) — keep the conversation id so the push deep-links + // precisely into the proactive thread instead of the generic + // `/coach`, closing the most-recent-heuristic gap on the web side. + nudgeConversationId = recorded.conversationId; summary.persisted += 1; } catch (persistErr: unknown) { const message = @@ -901,8 +906,12 @@ export async function runCoachNudgeTick( scheduledAt: now.toISOString(), trigger, // Deep link — the web-push sender threads this into the - // service-worker payload's click target. - url: "/coach", + // service-worker payload's click target. Point at the exact + // proactive conversation when it persisted; fall back to the + // generic Coach surface if persistence failed. + url: nudgeConversationId + ? `/coach?c=${nudgeConversationId}` + : "/coach", }, }); diff --git a/src/lib/openapi/routes/coach.ts b/src/lib/openapi/routes/coach.ts index b74c987bd..e7ad0996e 100644 --- a/src/lib/openapi/routes/coach.ts +++ b/src/lib/openapi/routes/coach.ts @@ -779,6 +779,12 @@ export const coachPaths: NonNullable = { .describe( "True when that message is newer than the last time the caller opened the Coach.", ), + conversationId: z + .string() + .nullable() + .describe( + "Conversation holding the newest assistant message; null when none exists. The client deep-links into it (`/coach?c=`) on the unread path.", + ), }), "CoachNudgeStatus", ),