mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
914fa8d36e
commit
9ef79b2357
6 files changed
+80
-10
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
|
||||
Reference in new issue
Block a user