mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
merge: Coach v1.18.6 nudge and conversation continuity
Add the Coach proactive nudge-status surface and last-seen tracking (migration renumbered to 0176) plus the seen endpoint.
This commit is contained in:
commit
6614e0646f
15 files changed
+455
-63
No files matched your search
@@ -0,0 +1,15 @@
|
||||
-- v1.18.6 (CCH-03) — Coach "last seen" stamp for the FAB unread dot.
|
||||
--
|
||||
-- A single nullable timestamp on `users`. The proactive Coach nudge now
|
||||
-- lands as an ASSISTANT message in the conversation rail (CCH-02); the
|
||||
-- Coach FAB shows a discreet unread dot when the newest Coach message is
|
||||
-- newer than this stamp, and clears it once the user opens the Coach
|
||||
-- (the open writes the current time here via POST
|
||||
-- /api/insights/coach/seen).
|
||||
--
|
||||
-- NULL backfills every existing row to "never opened" — harmless: the
|
||||
-- dot only paints when an actual newer Coach message exists, so a user
|
||||
-- with no proactive nudge yet sees nothing. No data migration needed.
|
||||
|
||||
ALTER TABLE "users"
|
||||
ADD COLUMN "coach_last_seen_at" TIMESTAMP(3);
|
||||
@@ -464,6 +464,18 @@ model User {
|
||||
// `GET /api/auth/me` as a `modules: { <key>: boolean }` map.
|
||||
modulePreferencesJson Json? @map("module_preferences_json")
|
||||
|
||||
// v1.18.6 (CCH-03) — "Coach last seen" stamp powering the unread dot
|
||||
// on the Coach FAB. The proactive nudge now lands as an ASSISTANT
|
||||
// message in the conversation rail (CCH-02); the FAB shows a discreet
|
||||
// unread dot when the newest Coach message is newer than this stamp,
|
||||
// and clears it when the user opens the Coach (drawer or page, which
|
||||
// writes the current time here via `POST /api/insights/coach/seen`).
|
||||
// Null = never opened the Coach, so any existing message reads as
|
||||
// unread once. Server-authoritative so the signal is consistent across
|
||||
// web + iOS; the FAB keeps a local mirror only as an instant-paint
|
||||
// optimisation.
|
||||
coachLastSeenAt DateTime? @map("coach_last_seen_at")
|
||||
|
||||
// v1.5.5 — self-hosted avatar storage.
|
||||
//
|
||||
// Replaces the Gravatar third-party leak (the old `getGravatarUrl`
|
||||
|
||||
@@ -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";
|
||||
+30
-9
@@ -63,15 +63,35 @@ export default function CoachPage() {
|
||||
<div
|
||||
data-slot="coach-page"
|
||||
data-tour-id="coach-hero"
|
||||
// v1.18.1 (W-COACH-UI C1/C3) — the card was capped at
|
||||
// `100dvh-13rem`, leaving a band of wasted space below it on a
|
||||
// standalone route that no longer carries the Insights tab strip.
|
||||
// Claim the page height down to a comfortable bottom gutter so the
|
||||
// thread + rail fill the viewport, and lift the card with a soft
|
||||
// shadow so it reads as one contained surface rather than a thin
|
||||
// outline.
|
||||
className="bg-card flex h-[calc(100dvh-8rem)] min-h-[32rem] flex-col overflow-hidden rounded-2xl border shadow-sm"
|
||||
// v1.18.6 (CCH-01) — Marc: the Coach read "cut off" vs other pages
|
||||
// and wanted more width / a focused Claude-like surface. The page
|
||||
// sits inside `<AuthShell>`'s shared `mx-auto max-w-screen-xl px-4
|
||||
// pt-6 pb-20 md:px-6` content frame, which boxed the chat into the
|
||||
// same narrow column every dashboard card uses and left a tall dead
|
||||
// band below the card (the `pb-20` FAB gutter, pointless here since
|
||||
// the FAB hides on `/coach`). Break out of that frame: negative
|
||||
// margins cancel the container's horizontal + top/bottom padding so
|
||||
// the chat claims the full content width and the viewport height,
|
||||
// then a light inner padding keeps it off the window edge. The card
|
||||
// itself drops its rounded border on small screens (edge-to-edge,
|
||||
// app-like) and keeps the contained card look from `sm+`.
|
||||
//
|
||||
// v1.18.1 (W-COACH-UI C1/C3) — the card formerly capped at
|
||||
// `100dvh-13rem`, leaving wasted space below it; we now fill the
|
||||
// reclaimed height.
|
||||
className={
|
||||
// Cancel the AuthShell container's padding (`-mt-6` top,
|
||||
// `-mb-20` bottom, `-mx-4 md:-mx-6` sides) so the surface is
|
||||
// full-bleed, then re-pad lightly. The height is the full
|
||||
// viewport minus the top bar (`4rem`) and the small gutters.
|
||||
"-mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-4rem)] min-h-[32rem] flex-col md:-mx-6 " +
|
||||
"px-2 pt-2 pb-3 sm:px-4 sm:pt-4 sm:pb-4"
|
||||
}
|
||||
>
|
||||
<div
|
||||
data-slot="coach-page-card"
|
||||
className="bg-card flex min-h-0 flex-1 flex-col overflow-hidden rounded-none border-y shadow-sm sm:rounded-2xl sm:border"
|
||||
>
|
||||
<CoachConversation
|
||||
surface="page"
|
||||
autoFocusComposer
|
||||
@@ -95,7 +115,8 @@ export default function CoachPage() {
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -78,7 +78,18 @@ export function CoachDrawerBody({
|
||||
<h2 className="border-border/70 text-muted-foreground flex h-14 shrink-0 items-center border-b px-4 text-xs font-medium tracking-wide uppercase">
|
||||
{t("insights.coach.historyTitle")}
|
||||
</h2>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">{historyRail}</div>
|
||||
{/* v1.18.6 (CCH-01) — the wrapping `overflow-y-auto` div here
|
||||
was the broken left scrollbar: `<HistoryRail>` is itself a
|
||||
`flex h-full flex-col` with its OWN `overflow-y-auto` list,
|
||||
so this nested it inside a second scroll container. The
|
||||
outer container scrolled the whole rail (search field
|
||||
included) against an `h-full` child whose height resolved to
|
||||
its content, leaving the inner list's `flex-1 min-h-0`
|
||||
without a bounded track — the scrollbar rendered against an
|
||||
indeterminate height and read as half-broken. Drop the outer
|
||||
scroll: the rail keeps a fixed search header and its single
|
||||
internal list-scroll is the only scroll region. */}
|
||||
<div className="flex min-h-0 flex-1 flex-col">{historyRail}</div>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
|
||||
@@ -363,6 +363,13 @@ export function MessageThread({
|
||||
data-slot="coach-message-thread"
|
||||
className={cn(
|
||||
"flex h-full flex-col gap-4 overflow-y-auto px-4 py-4 sm:px-6",
|
||||
// v1.18.6 (CCH-01) — on the now full-width page surface a thread
|
||||
// stretched edge-to-edge sprawled the prose to an unreadable
|
||||
// measure. Centre the content on a comfortable max width (Claude-
|
||||
// like) via an `mx-auto` inner gutter; the scrollbar still rides
|
||||
// the surface edge. The drawer surface is already narrow, so the
|
||||
// cap only bites on the wide page surface.
|
||||
"[&>*]:mx-auto [&>*]:w-full [&>*]:max-w-3xl",
|
||||
"scroll-smooth",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Sparkles } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -13,18 +13,26 @@ import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
import { useDisableCoach } from "@/hooks/use-disable-coach";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
|
||||
/**
|
||||
* v1.16.8 — the Coach FAB is a permanent launcher again, now on EVERY
|
||||
* authenticated page (mounted once in `<AuthShell>`, no longer scoped
|
||||
* to `/insights/**`). The v1.16.1 nudge-only bubble folded into it: the
|
||||
* FAB always renders, and an unseen proactive `COACH_NUDGE` (surfaced
|
||||
* FAB always renders, and an unread proactive Coach message (surfaced
|
||||
* by `/api/insights/coach/nudge-status`) paints a small unread dot on
|
||||
* its corner instead of toggling the whole button. The dot clears once
|
||||
* the nudge counts as read — the user sent a Coach message after the
|
||||
* nudge (server-derived), or opened the Coach on this device (local
|
||||
* seen stamp keyed by the nudge timestamp).
|
||||
* its corner instead of toggling the whole button.
|
||||
*
|
||||
* v1.18.6 (CCH-02/CCH-03) — the proactive nudge now lands as a real
|
||||
* ASSISTANT message in the conversation rail, not a notification-only
|
||||
* dispatch, so the unread signal moved onto the server-authoritative
|
||||
* `User.coachLastSeenAt` stamp: `status.unread` is true while a Coach
|
||||
* message is newer than the last time the user opened the Coach. Opening
|
||||
* the Coach (this FAB, or the `/coach` page) fires
|
||||
* `POST /api/insights/coach/seen`, which stamps the server and clears
|
||||
* the dot everywhere (web + iOS). A local "seen" stamp is kept only as
|
||||
* an instant-paint optimisation so the dot disappears the moment the
|
||||
* user taps, before the mutation round-trips.
|
||||
*
|
||||
* The FAB hides inside the Coach view itself (`/coach`) — a
|
||||
* launcher pointing at the page the user is already on is noise.
|
||||
@@ -44,9 +52,12 @@ export interface CoachNudgeStatus {
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure unread derivation — server signal AND-ed with the local
|
||||
* seen-stamp. Exported so the unit test pins the contract without a
|
||||
* QueryClient round-trip.
|
||||
* Pure unread derivation — the server signal is authoritative
|
||||
* (`status.unread` already compares the newest Coach message against the
|
||||
* persisted `coachLastSeenAt`). The local seen-stamp only suppresses the
|
||||
* dot for an instant after the user taps, before the mark-seen mutation
|
||||
* + status refetch land. Exported so the unit test pins the contract
|
||||
* without a QueryClient round-trip.
|
||||
*/
|
||||
export function isNudgeUnread(
|
||||
status: CoachNudgeStatus | undefined,
|
||||
@@ -77,6 +88,7 @@ export function LayoutCoachFab() {
|
||||
|
||||
const coachAvailable = !!launch && flags.coach && !disableCoach;
|
||||
const onCoachPage = pathname?.startsWith("/coach") ?? false;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: status } = useQuery({
|
||||
queryKey: queryKeys.coachNudgeStatus(),
|
||||
@@ -87,6 +99,22 @@ export function LayoutCoachFab() {
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
// v1.18.6 (CCH-03) — stamp `coachLastSeenAt` server-side when the user
|
||||
// opens the Coach so the dot clears across every device. Fire-and-
|
||||
// forget from the UI's perspective (the local seen-stamp already
|
||||
// suppressed the dot); on success the status query refetches so the
|
||||
// server-authoritative `unread` re-resolves to false.
|
||||
const markSeen = useMutation({
|
||||
mutationKey: queryKeys.coachMarkSeen(),
|
||||
mutationFn: async () =>
|
||||
apiPost<{ seenAt: string }>("/api/insights/coach/seen", {}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.coachNudgeStatus(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const nudgedAt = status?.nudgedAt ?? null;
|
||||
const unread = isNudgeUnread(status, seenStamp);
|
||||
|
||||
@@ -107,16 +135,30 @@ export function LayoutCoachFab() {
|
||||
prevUnreadRef.current = unread;
|
||||
}, [unread, t]);
|
||||
|
||||
// Visiting the Coach page itself counts as reading the nudge on this
|
||||
// device — persist the stamp so the dot stays gone after leaving.
|
||||
// Visiting the Coach page itself counts as reading the nudge — persist
|
||||
// the local instant-paint stamp AND stamp the server so the dot stays
|
||||
// gone after leaving, on every device. Fired once per page entry: the
|
||||
// ref guards against the effect re-running on unrelated re-renders.
|
||||
const seenStampedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!onCoachPage || nudgedAt === null) return;
|
||||
try {
|
||||
window.localStorage.setItem(NUDGE_SEEN_STORAGE_KEY, nudgedAt);
|
||||
} catch {
|
||||
// Storage unavailable (private mode) — the server-side read
|
||||
// signal still clears the dot once the user sends a message.
|
||||
if (!onCoachPage) {
|
||||
seenStampedRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (seenStampedRef.current) return;
|
||||
seenStampedRef.current = true;
|
||||
if (nudgedAt !== null) {
|
||||
try {
|
||||
window.localStorage.setItem(NUDGE_SEEN_STORAGE_KEY, nudgedAt);
|
||||
} catch {
|
||||
// Storage unavailable (private mode) — the server stamp below
|
||||
// still clears the dot once it round-trips.
|
||||
}
|
||||
}
|
||||
markSeen.mutate();
|
||||
// `markSeen` is a stable mutation object; excluding it keeps the
|
||||
// effect from re-firing on its internal state changes.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [onCoachPage, nudgedAt]);
|
||||
|
||||
if (!coachAvailable) return null;
|
||||
@@ -136,6 +178,10 @@ export function LayoutCoachFab() {
|
||||
// Best effort — see above.
|
||||
}
|
||||
}
|
||||
// v1.18.6 (CCH-03) — stamp the server so the dot clears on every
|
||||
// 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.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.
|
||||
@@ -194,10 +240,15 @@ export function LayoutCoachFab() {
|
||||
>
|
||||
<Sparkles className="size-6" aria-hidden="true" />
|
||||
{unread ? (
|
||||
// v1.18.6 (CCH-03) — discreet "the Coach said something" dot.
|
||||
// Deliberately NOT an alarming red (the medication-card rule:
|
||||
// status via calm signal, never an alarm tint): a small cyan
|
||||
// dot ringed against the FAB background reads as informative,
|
||||
// not urgent.
|
||||
<span
|
||||
data-slot="coach-fab-unread"
|
||||
aria-hidden="true"
|
||||
className="border-background bg-dracula-red absolute top-0.5 right-0.5 size-3 rounded-full border-2"
|
||||
className="border-background bg-dracula-cyan absolute top-0.5 right-0.5 size-3 rounded-full border-2"
|
||||
/>
|
||||
) : null}
|
||||
</Button>
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
|
||||
import { TargetAdjustButton } from "@/components/insights/target-adjust-button";
|
||||
import { TargetAdjustProvider } from "@/lib/insights/target-adjust-context";
|
||||
import { useScrollResetOnRoute } from "@/hooks/use-scroll-reset-on-route";
|
||||
@@ -108,13 +107,12 @@ export interface SubPageShellProps {
|
||||
/**
|
||||
* v1.8.6 — opt-in Coach launch in the page header.
|
||||
*
|
||||
* When true, the shell mounts an icon-only `<CoachLaunchButton>` at
|
||||
* the top-right of the header, vertically aligned with the heading, so
|
||||
* "Coach fragen" reads as a header action rather than a foot-of-page
|
||||
* button. Every routed category page passes it; the mother page and
|
||||
* empty-state branches omit it. The button self-gates on the Coach
|
||||
* feature flag + per-user opt-out, so a disabled-Coach tenant shows
|
||||
* nothing.
|
||||
* v1.18.6 (CCH-04) — RETIRED as a rendered control. With the Coach FAB
|
||||
* anchored bottom-right on every authenticated page, the per-metric
|
||||
* Coach icon was a redundant second entry point, so the shell no longer
|
||||
* paints anything for it. The prop stays on the signature (every routed
|
||||
* category page still passes it) to avoid a churny rename across ~14
|
||||
* call sites; it is intentionally inert.
|
||||
*/
|
||||
coachLaunch?: boolean;
|
||||
/**
|
||||
@@ -139,7 +137,10 @@ export function SubPageShell({
|
||||
focusOnMount = false,
|
||||
statStrip,
|
||||
diversityNudge,
|
||||
coachLaunch = false,
|
||||
// v1.18.6 (CCH-04) — `coachLaunch` is intentionally not destructured:
|
||||
// the prop is still accepted on the type (call sites pass it) but the
|
||||
// shell no longer renders a Coach control. The FAB is the single Coach
|
||||
// entry point app-wide.
|
||||
showAllValuesType,
|
||||
children,
|
||||
}: SubPageShellProps) {
|
||||
@@ -270,7 +271,12 @@ export function SubPageShell({
|
||||
</Button>
|
||||
) : null}
|
||||
<TargetAdjustButton />
|
||||
{coachLaunch ? <CoachLaunchButton variant="icon" /> : null}
|
||||
{/* v1.18.6 (CCH-04) — the per-metric Coach launch icon is
|
||||
gone. With the Coach FAB anchored bottom-right on every
|
||||
authenticated page, a second Coach affordance in each
|
||||
metric header was redundant. The `coachLaunch` prop is
|
||||
still accepted (every metric page passes it) but no longer
|
||||
paints a control — the FAB is the single Coach entry. */}
|
||||
{/* v1.16.8 — the v1.16.4 customise cog is gone: the sticky
|
||||
tab strip above already carries the same control on every
|
||||
insights surface, so the header copy was a duplicate. */}
|
||||
|
||||
@@ -1,6 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { summariseTitle } from "../persistence";
|
||||
// Mock the db + crypto boundaries so `recordProactiveNudge` can be
|
||||
// exercised without a real Postgres / encryption key. The transaction
|
||||
// runner simply invokes the callback with the stubbed `tx`.
|
||||
const txCreate = {
|
||||
coachConversation: { create: vi.fn() },
|
||||
coachMessage: { create: vi.fn() },
|
||||
};
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
$transaction: vi.fn(
|
||||
async (cb: (tx: typeof txCreate) => Promise<unknown>) => cb(txCreate),
|
||||
),
|
||||
},
|
||||
}));
|
||||
vi.mock("../bytes-codec", () => ({
|
||||
encryptToBytes: vi.fn((text: string) =>
|
||||
new TextEncoder().encode(`enc:${text}`),
|
||||
),
|
||||
decryptFromBytes: vi.fn(),
|
||||
}));
|
||||
|
||||
import { recordProactiveNudge, summariseTitle } from "../persistence";
|
||||
import { encryptToBytes } from "../bytes-codec";
|
||||
|
||||
describe("summariseTitle", () => {
|
||||
it("returns the input unchanged when below 80 chars", () => {
|
||||
@@ -41,3 +63,51 @@ describe("summariseTitle", () => {
|
||||
expect(summariseTitle(" ")).toBe("New conversation");
|
||||
});
|
||||
});
|
||||
|
||||
describe("recordProactiveNudge", () => {
|
||||
it("creates a conversation + an encrypted ASSISTANT message in one transaction", async () => {
|
||||
const now = new Date("2026-06-18T05:15:00.000Z");
|
||||
txCreate.coachConversation.create.mockResolvedValue({
|
||||
id: "conv_1",
|
||||
userId: "user_1",
|
||||
title: "Time to weigh in",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
txCreate.coachMessage.create.mockResolvedValue({
|
||||
id: "msg_1",
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
const out = await recordProactiveNudge({
|
||||
userId: "user_1",
|
||||
title: "Time to weigh in",
|
||||
body: "It has been a week — a quick weigh-in keeps the trend honest.",
|
||||
});
|
||||
|
||||
expect(out).toEqual({
|
||||
conversationId: "conv_1",
|
||||
messageId: "msg_1",
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
// Conversation owned by the user, title summarised.
|
||||
expect(txCreate.coachConversation.create).toHaveBeenCalledWith({
|
||||
data: { userId: "user_1", title: "Time to weigh in" },
|
||||
});
|
||||
|
||||
// The body is encrypted at rest (Bytes), role is assistant, and the
|
||||
// message hangs off the new conversation. No raw plaintext column.
|
||||
const msgArg = txCreate.coachMessage.create.mock.calls[0][0];
|
||||
expect(msgArg.data.conversationId).toBe("conv_1");
|
||||
expect(msgArg.data.role).toBe("assistant");
|
||||
expect(msgArg.data.providerType).toBe("nudge");
|
||||
expect(encryptToBytes).toHaveBeenCalledWith(
|
||||
"It has been a week — a quick weigh-in keeps the trend honest.",
|
||||
);
|
||||
// The stored column is the ciphertext, never the plaintext body.
|
||||
expect(new TextDecoder().decode(msgArg.data.encryptedContent)).toContain(
|
||||
"enc:",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -188,6 +188,60 @@ export async function appendMessage(
|
||||
};
|
||||
}
|
||||
|
||||
export interface RecordProactiveNudgeParams {
|
||||
userId: string;
|
||||
/** Conversation title (the nudge headline) — summarised to ≤80 chars. */
|
||||
title: string;
|
||||
/** The nudge body, persisted as the initial ASSISTANT message. */
|
||||
body: string;
|
||||
/** Trigger tag for the wide-event annotation on the caller side. */
|
||||
providerType?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.18.6 (CCH-02) — record a proactive Coach nudge as a real
|
||||
* conversation so it shows up in the conversation rail regardless of
|
||||
* which push channel (if any) the user configured. The proactive cron
|
||||
* used to dispatch a notification ONLY; with no push channel the nudge
|
||||
* was entirely invisible.
|
||||
*
|
||||
* Creates a fresh conversation and writes the nudge as the initial
|
||||
* ASSISTANT message in one transaction so a partial write never leaves
|
||||
* an empty thread in the rail. The body crosses the same
|
||||
* `encryptToBytes` boundary as every other Coach message, so the nudge
|
||||
* text is encrypted at rest like a normal reply. Returns the new
|
||||
* conversation + message ids for the caller's annotation.
|
||||
*/
|
||||
export async function recordProactiveNudge(
|
||||
params: RecordProactiveNudgeParams,
|
||||
): Promise<{ conversationId: string; messageId: string; createdAt: Date }> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const conversation = await tx.coachConversation.create({
|
||||
data: {
|
||||
userId: params.userId,
|
||||
title: summariseTitle(params.title),
|
||||
},
|
||||
});
|
||||
const message = await tx.coachMessage.create({
|
||||
data: {
|
||||
conversationId: conversation.id,
|
||||
role: "assistant",
|
||||
encryptedContent: encryptToBytes(params.body),
|
||||
metricSourceJson: null,
|
||||
providerType: params.providerType ?? "nudge",
|
||||
promptVersion: null,
|
||||
},
|
||||
});
|
||||
// The conversation's `createdAt` == `updatedAt` on creation, so the
|
||||
// rail already orders it to the top; no extra `update` needed.
|
||||
return {
|
||||
conversationId: conversation.id,
|
||||
messageId: message.id,
|
||||
createdAt: message.createdAt,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch one conversation + every message, decrypting each body on
|
||||
* read. Returns null when the conversation does not exist OR when the
|
||||
|
||||
@@ -714,4 +714,67 @@ describe("runCoachNudgeTick — gates and prefs", () => {
|
||||
"You wanted to keep an eye on: morning blood pressure.",
|
||||
);
|
||||
});
|
||||
|
||||
it("persists the nudge as a conversation before dispatching (CCH-02)", async () => {
|
||||
const order: string[] = [];
|
||||
const recordNudge = vi.fn(
|
||||
async (args: { userId: string; title: string; body: string }) => {
|
||||
void args;
|
||||
order.push("persist");
|
||||
return {
|
||||
conversationId: "conv_1",
|
||||
messageId: "msg_1",
|
||||
createdAt: now,
|
||||
};
|
||||
},
|
||||
);
|
||||
const dispatch = vi.fn(async () => {
|
||||
order.push("dispatch");
|
||||
return { dispatched: true };
|
||||
});
|
||||
const prisma = prismaMock({
|
||||
users: [userRow(null)],
|
||||
intakeRows: failingIntakes,
|
||||
});
|
||||
const summary = await runCoachNudgeTick(
|
||||
prisma as unknown as PrismaClient,
|
||||
now,
|
||||
{ dispatch: dispatch as never, recordNudge: recordNudge as never },
|
||||
);
|
||||
expect(summary.dispatched).toBe(1);
|
||||
expect(summary.persisted).toBe(1);
|
||||
// The conversation must be written BEFORE the notification fires so
|
||||
// the rail always carries the nudge even if the push channel is gone.
|
||||
expect(order).toEqual(["persist", "dispatch"]);
|
||||
const persistArg = recordNudge.mock.calls[0]?.[0] as unknown as {
|
||||
userId: string;
|
||||
title: string;
|
||||
body: string;
|
||||
};
|
||||
expect(persistArg.userId).toBe("user-1");
|
||||
// The persisted body is the same compliance nudge copy the
|
||||
// notification carries — it lands as the initial assistant message.
|
||||
expect(persistArg.body).toContain("Your intakes were patchy this week");
|
||||
expect(persistArg.title.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("still dispatches + counts the nudge when persistence throws", async () => {
|
||||
const recordNudge = vi.fn(async () => {
|
||||
throw new Error("db down");
|
||||
});
|
||||
const dispatch = vi.fn(async () => ({ dispatched: true }));
|
||||
const prisma = prismaMock({
|
||||
users: [userRow(null)],
|
||||
intakeRows: failingIntakes,
|
||||
});
|
||||
const summary = await runCoachNudgeTick(
|
||||
prisma as unknown as PrismaClient,
|
||||
now,
|
||||
{ dispatch: dispatch as never, recordNudge: recordNudge as never },
|
||||
);
|
||||
// A persistence failure must not swallow the notification.
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(summary.dispatched).toBe(1);
|
||||
expect(summary.persisted).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -73,6 +73,7 @@ import {
|
||||
import { dispatchNotification } from "@/lib/notifications/dispatcher";
|
||||
import { resolveCoachNudgePrefs } from "@/lib/validations/notification-prefs";
|
||||
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { recordProactiveNudge } from "@/lib/ai/coach/persistence";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { defaultLocale, locales } from "@/lib/i18n/config";
|
||||
@@ -167,6 +168,13 @@ export const COACH_NUDGE_TRIGGER_GROUPS: Record<
|
||||
export interface CoachNudgeSummary {
|
||||
candidatesScanned: number;
|
||||
dispatched: number;
|
||||
/**
|
||||
* v1.18.6 (CCH-02) — nudges written into the conversation rail as an
|
||||
* initial ASSISTANT message. Counted independently of `dispatched`:
|
||||
* the conversation lands even when no push channel is configured, so
|
||||
* `persisted` can exceed `dispatched`.
|
||||
*/
|
||||
persisted: number;
|
||||
skippedOptedOut: number;
|
||||
skippedNoProvider: number;
|
||||
skippedRecentNudge: number;
|
||||
@@ -695,13 +703,16 @@ export async function runCoachNudgeTick(
|
||||
now: Date,
|
||||
options: {
|
||||
dispatch?: typeof dispatchNotification;
|
||||
recordNudge?: typeof recordProactiveNudge;
|
||||
} = {},
|
||||
): Promise<CoachNudgeSummary> {
|
||||
const dispatchImpl = options.dispatch ?? dispatchNotification;
|
||||
const recordNudgeImpl = options.recordNudge ?? recordProactiveNudge;
|
||||
|
||||
const summary: CoachNudgeSummary = {
|
||||
candidatesScanned: 0,
|
||||
dispatched: 0,
|
||||
persisted: 0,
|
||||
skippedOptedOut: 0,
|
||||
skippedNoProvider: 0,
|
||||
skippedRecentNudge: 0,
|
||||
@@ -839,6 +850,30 @@ export async function runCoachNudgeTick(
|
||||
user.locale,
|
||||
coachFocus,
|
||||
);
|
||||
|
||||
// v1.18.6 (CCH-02) — persist the nudge as a real conversation
|
||||
// BEFORE dispatching the notification. The proactive nudge used to
|
||||
// be notification-only, so a user with no push channel never saw
|
||||
// it anywhere; writing it as the initial ASSISTANT message means it
|
||||
// always lands in the conversation rail (and clears the FAB unread
|
||||
// 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.
|
||||
try {
|
||||
await recordNudgeImpl({
|
||||
userId: user.id,
|
||||
title,
|
||||
body,
|
||||
});
|
||||
summary.persisted += 1;
|
||||
} catch (persistErr: unknown) {
|
||||
const message =
|
||||
persistErr instanceof Error ? persistErr.message : String(persistErr);
|
||||
getEvent()?.addWarning(
|
||||
`coach-nudge conversation persist failed for ${user.id}: ${message}`,
|
||||
);
|
||||
}
|
||||
|
||||
const outcome = await dispatchImpl({
|
||||
eventType: "COACH_NUDGE",
|
||||
userId: user.id,
|
||||
|
||||
@@ -306,6 +306,7 @@ export async function registerStatusQueues(
|
||||
result: {
|
||||
candidates_scanned: summary.candidatesScanned,
|
||||
dispatched: summary.dispatched,
|
||||
persisted: summary.persisted,
|
||||
skipped_opted_out: summary.skippedOptedOut,
|
||||
skipped_no_provider: summary.skippedNoProvider,
|
||||
skipped_recent_nudge: summary.skippedRecentNudge,
|
||||
|
||||
@@ -11,4 +11,6 @@ export const coachKeys = {
|
||||
coachAboutMeQuestions: () => ["coach-about-me", "questions"] as const,
|
||||
/** v1.16.1 — unseen proactive-nudge state for the floating Coach bubble. */
|
||||
coachNudgeStatus: () => ["coach-nudge-status"] as const,
|
||||
/** v1.18.6 (CCH-03) — mark-Coach-seen mutation (clears the FAB unread dot). */
|
||||
coachMarkSeen: () => ["coach-mark-seen"] as const,
|
||||
};
|
||||
Reference in new issue
Block a user