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

@@ -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.