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=<id> 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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 13:31:17 +02:00
1 parent 914fa8d36e
commit 9ef79b2357
6 files changed
+80 -10

No files matched your search

+7
View File
@@ -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=<id>`) on the unread path.
required:
- nudgedAt
- unread
- conversationId
additionalProperties: false
error:
type: "null"
@@ -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=<id>`)
// 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,
});
});
@@ -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);
});
});
+19 -1
View File
@@ -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=<id>`) 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.
+12 -3
View File
@@ -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",
},
});
+6
View File
@@ -779,6 +779,12 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
.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=<id>`) on the unread path.",
),
}),
"CoachNudgeStatus",
),