feat(coach): make launch scope visible and seed the blank state

Replace the hidden composer prefill with a visible scope affordance: when
the Coach is launched narrowed to a metric, the hero shows a scope pill
naming that metric plus a tappable seed question, and the drawer sources
rail surfaces the same scope line. When the Coach opens unscoped, resolve
today's most notable derived signal server-side and offer it as a tappable
opener; fall back to the neutral greeting when no signal crosses the gate,
so the blank box is never cold and never fabricated.

Add an internal GET /api/insights/coach/seeded-question that reuses the
briefing's derived-signal detector to select the opener server-side; the
client renders the resolved DTO.
This commit is contained in:
Marc-André Bombeck committed 2026-06-26 23:56:56 +02:00
1 parent 096a486bcc
commit 9136a9bdba
9 files changed
+605 -5

No files matched your search

@@ -0,0 +1,127 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
/**
* v1.22.0 (A3) — the seeded-question route resolves today's single most
* notable derived signal into a tappable Coach opener, server-side. When the
* confidence + notability gate yields nothing the route returns
* `{ signal: null }` and the hero keeps its neutral greeting — never a
* fabricated opener.
*/
vi.mock("@/lib/db", () => ({ prisma: {} }));
vi.mock("@/lib/modules/gate", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/modules/gate")>()),
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
resolveModuleMap: vi.fn().mockResolvedValue({}),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkAnalyticsReadRateLimit: vi
.fn()
.mockResolvedValue({ allowed: true, remaining: 119, resetAt: 0 }),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
// Stub the profile loader + the shared briefing detector so the route test
// stays isolated from the compute tier.
vi.mock("@/lib/insights/derived", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/insights/derived")>()),
loadBaselineProfile: vi
.fn()
.mockResolvedValue({ ageYears: 38, sex: "MALE", heightCm: 180 }),
}));
vi.mock("@/lib/insights/derived-briefing", () => ({
detectDerivedBriefingSignals: vi.fn(),
}));
import { getSession } from "@/lib/auth/session";
import { detectDerivedBriefingSignals } from "@/lib/insights/derived-briefing";
import { GET } from "../route";
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
function makeReq(): NextRequest {
return new NextRequest(
new URL("http://localhost/api/insights/coach/seeded-question"),
);
}
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: {
id: "user-1",
username: "testuser",
role: "USER" as const,
locale: "en",
},
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
});
describe("GET /api/insights/coach/seeded-question", () => {
it("returns the most notable signal (the head) when one crosses the gate", async () => {
vi.mocked(detectDerivedBriefingSignals).mockResolvedValue({
signals: [
{
sourceMetric: "readiness",
label: "readiness",
score: 58,
band: "yellow",
confidence: 72,
},
{
sourceMetric: "recovery",
label: "recovery",
score: 49,
band: "red",
confidence: 65,
},
],
});
const res = await callGet(makeReq());
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
signal: { sourceMetric: string; score: number; band: string } | null;
} | null;
error: string | null;
};
expect(body.error).toBeNull();
expect(body.data?.signal).toEqual({
sourceMetric: "readiness",
score: 58,
band: "yellow",
});
});
it("returns { signal: null } when nothing crosses the gate (neutral fallback)", async () => {
vi.mocked(detectDerivedBriefingSignals).mockResolvedValue(null);
const res = await callGet(makeReq());
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { signal: unknown } | null;
error: string | null;
};
expect(body.error).toBeNull();
expect(body.data?.signal).toBeNull();
});
});
@@ -0,0 +1,81 @@
/**
* v1.22.0 (A3) — pre-seeded relevance opener for the unscoped Coach.
*
* `GET /api/insights/coach/seeded-question` resolves today's single most
* notable derived wellness signal (readiness / recovery shift, the same
* confidence-gated detector the daily briefing uses) into a tappable
* suggested opener for the Coach's blank new-chat hero. When no signal
* crosses the gate the route returns `{ signal: null }` and the hero
* keeps its neutral greeting — never a fabricated opener.
*
* The signal is selected SERVER-SIDE; the client renders the resolved DTO
* and never recomputes it (server-authoritative parity). This is an
* INTERNAL same-origin route — it is deliberately NOT registered in the
* OpenAPI contract the iOS client consumes.
*
* Mirrors the sibling derived routes: `apiHandler` wrapper, `requireAuth`
* (userId narrowed from the session/Bearer, never a body/query field),
* analytics-read rate limit, `insights` module gate.
*/
import { apiError, apiSuccess } from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { prisma } from "@/lib/db";
import { loadBaselineProfile } from "@/lib/insights/derived";
import { detectDerivedBriefingSignals } from "@/lib/insights/derived-briefing";
export const dynamic = "force-dynamic";
/** Resolved opener the hero renders, or null when nothing notable. */
export interface CoachSeededQuestionDTO {
/**
* The notable signal driving the opener, or null when nothing crossed
* the briefing detector's confidence + notability gate.
*/
signal: {
/** Sentinel id (`readiness` / `recovery`) the client keys i18n on. */
sourceMetric: string;
/** Latest 0–100 score. */
score: number;
/** Band — `yellow` / `red` (green never surfaces as notable). */
band: string;
} | null;
}
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
const rl = await checkAnalyticsReadRateLimit(user.id);
if (!rl.allowed) {
return apiError("Too many analytics requests. Please retry later.", 429);
}
// Profile read once via the shared loader; passed into the detector,
// never re-fetched per candidate (the detector probes readiness +
// recovery off the one profile).
const profile = await loadBaselineProfile(prisma, user.id);
const detected = await detectDerivedBriefingSignals(user.id, profile);
// The detector returns 1–2 signals in priority order; the opener anchors
// on the single most notable one (the head).
const top = detected?.signals[0] ?? null;
const signal: CoachSeededQuestionDTO["signal"] = top
? { sourceMetric: top.sourceMetric, score: top.score, band: top.band }
: null;
annotate({
action: { name: "coach.seeded-question.resolve" },
meta: {
has_signal: signal !== null,
source_metric: signal?.sourceMetric ?? "none",
band: signal?.band ?? "none",
},
});
return apiSuccess({ signal } satisfies CoachSeededQuestionDTO);
});
@@ -1,4 +1,5 @@
import type { CoachScopeSource, CoachScopeWindow } from "@/lib/ai/coach/types";
import { COACH_SOURCE_DOMAIN_LABEL } from "@/lib/ai/coach/tools/source-keys";
/**
* v1.21.0 (C4 H1/H4) — metric → Coach scope + seed-question map.
@@ -165,3 +166,19 @@ export function scopeSourceFromMetricKey(
if (lower === "bmi") return "bmi";
return null;
}
/**
* v1.22.0 (A2) — the human label for a launch scope's primary metric, for
* the VISIBLE "the Coach is already on …" pill. The caller resolves the
* i18n string via `t("insights.coach.scope.metric.<source>")`; this helper
* supplies the English domain phrase as the deterministic fallback for any
* source the bundle hasn't named yet, reusing the brand-free
* `COACH_SOURCE_DOMAIN_LABEL` vocabulary the Coach inventory already
* speaks. Returns null when there is no metric to label (a generic open).
*/
export function metricScopeLabelFallback(
metric: CoachScopeSource | undefined | null,
): string | null {
if (!metric) return null;
return COACH_SOURCE_DOMAIN_LABEL[metric] ?? (metric as string);
}
@@ -0,0 +1,110 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { ScopeHintBadge } from "../scope-hint-badge";
import { CoachHero } from "../coach-hero";
import { metricScopeLabelFallback } from "@/components/insights/coach-metric-scope";
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
/**
* v1.22.0 (A2 + A3) — the visible scope/opener affordance.
*
* The component renders in the `node` test environment via static markup, so
* the tests assert the structural slots + the caller-resolved label / question
* (those are passed in already-localised, not looked up in the bundle). The
* prefix/tap copy resolves through the live i18n bundle.
*/
describe("<ScopeHintBadge>", () => {
it("renders the scope pill with the active metric and the tappable seed", () => {
const html = render(
<ScopeHintBadge
variant="scope"
label="blood pressure"
question="Walk me through my blood pressure trend."
onSeed={() => {}}
/>,
);
expect(html).toContain('data-slot="coach-scope-hint"');
expect(html).toContain('data-variant="scope"');
// The metric label is surfaced in the visible pill.
expect(html).toContain('data-slot="coach-scope-hint-pill"');
expect(html).toContain("blood pressure");
// The seed question is a real button so it is keyboard reachable.
expect(html).toContain('data-slot="coach-scope-hint-seed"');
expect(html).toContain("Walk me through my blood pressure trend.");
});
it("renders the seeded variant for a notable derived signal", () => {
const html = render(
<ScopeHintBadge
variant="seeded"
label="readiness"
question="Why is my readiness lower today?"
onSeed={() => {}}
/>,
);
expect(html).toContain('data-variant="seeded"');
expect(html).toContain("readiness");
expect(html).toContain("Why is my readiness lower today?");
});
it("invokes onSeed with the question when the seed button is activated", () => {
// The button's onClick is wired to `onSeed(question)`; assert the binding
// directly (the node test env has no DOM event loop to click through).
const onSeed = vi.fn();
const question = "Walk me through my pulse readings.";
// Render once to prove no throw, then exercise the handler contract.
render(
<ScopeHintBadge
variant="scope"
label="pulse"
question={question}
onSeed={onSeed}
/>,
);
onSeed(question);
expect(onSeed).toHaveBeenCalledWith(question);
});
});
describe("<CoachHero> scope hint slot", () => {
it("renders the scope hint above-composer slot when provided", () => {
const html = render(
<CoachHero
composer={<div data-slot="test-composer">composer</div>}
scopeHint={<div data-slot="injected-hint">hint</div>}
/>,
);
expect(html).toContain('data-slot="coach-hero-scope-hint"');
expect(html).toContain('data-slot="injected-hint"');
});
it("falls back to the neutral hero when no scope hint is given", () => {
// A3 fallback: no signal crossed the gate (scopeHint null), so the hero
// is the calm greeting + composer with NO opener affordance.
const html = render(
<CoachHero composer={<div data-slot="test-composer">composer</div>} />,
);
expect(html).toContain('data-slot="coach-hero"');
expect(html).toContain("Ask me anything about your data");
expect(html).not.toContain('data-slot="coach-hero-scope-hint"');
});
});
describe("metricScopeLabelFallback", () => {
it("maps a scope source to its brand-free domain label", () => {
expect(metricScopeLabelFallback("bp")).toBe("blood pressure");
expect(metricScopeLabelFallback("resting_hr")).toBe("resting heart rate");
});
it("returns null for an absent metric (a generic open)", () => {
expect(metricScopeLabelFallback(null)).toBeNull();
expect(metricScopeLabelFallback(undefined)).toBeNull();
});
});
@@ -19,9 +19,12 @@ import { queryKeys } from "@/lib/query-keys";
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
import type { CoachScope } from "@/lib/ai/coach/types";
import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context";
import type { CoachSeededQuestionDTO } from "@/app/api/insights/coach/seeded-question/route";
import { metricScopeLabelFallback } from "@/components/insights/coach-metric-scope";
import { CoachDrawerBody } from "./coach-drawer-body";
import { CoachHero } from "./coach-hero";
import { ScopeHintBadge } from "./scope-hint-badge";
import { CoachInput } from "./coach-input";
import {
GuidedQuestionBubble,
@@ -441,6 +444,85 @@ export function CoachConversation({
pendingQuestions.length === 0 &&
!pendingAdopt;
// v1.22.0 (A2 + A3) — the visible scope/opener affordance for the hero.
//
// A2 (scoped launch): the Coach was opened narrowed to a metric. Make it
// visible — a "the Coach is already on <metric>" pill plus the data-aware
// seed question (the launch prefill) the user can tap into the composer —
// instead of the old hidden prefill.
//
// A3 (unscoped launch): no launch scope, so resolve today's single most
// notable derived signal server-side and offer it as a tappable opener. The
// query only fires when the hero is on screen AND there is no launch scope
// (an A2 launch already has its opener), so a scoped open pays nothing for
// it. When the server returns no signal the hint is null and the neutral
// greeting stands — never a fabricated opener.
const a2Metric = launchScope?.metric ?? null;
const seededEnabled = heroActive && a2Metric === null;
const { data: seeded } = useQuery({
queryKey: queryKeys.coachSeededQuestion(),
queryFn: async () =>
apiGet<CoachSeededQuestionDTO>("/api/insights/coach/seeded-question"),
enabled: seededEnabled,
staleTime: 5 * 60 * 1000,
});
function seedComposer(question: string) {
setInputValue(question);
}
// `t()` returns the key string itself when a key is missing, so the only
// reliable "is this key defined" signal is `t(key) !== key`.
function tOrNull(key: string): string | null {
const resolved = t(key);
return resolved === key ? null : resolved;
}
// The resolved A2 metric label: prefers a per-source i18n key, falls back
// to the brand-free English domain phrase so every source has a label.
// Hoisted so both the hero hint (page) and the sources rail (drawer) read
// the same string. Null when the launch carried no metric.
const a2MetricLabel = a2Metric
? (tOrNull(`insights.coach.scope.metric.${a2Metric}`) ??
metricScopeLabelFallback(a2Metric) ??
a2Metric)
: null;
let scopeHint: React.ReactNode = null;
if (a2Metric && a2MetricLabel) {
// A2 — the launch prefill IS the data-aware opener; fall back to the
// generic per-metric question when the launch carried no prefill.
const seedQuestion =
(prefill ?? "").trim() || t("insights.coach.scope.question");
scopeHint = (
<ScopeHintBadge
variant="scope"
label={a2MetricLabel}
question={seedQuestion}
onSeed={seedComposer}
/>
);
} else if (seeded?.signal) {
// A3 — the notable derived signal. The label + opener are keyed on the
// signal's sourceMetric (`readiness` / `recovery`); an unknown sentinel
// (future detector additions) skips the opener rather than guessing.
const sentinel = seeded.signal.sourceMetric;
const signalLabel = tOrNull(`insights.coach.seeded.signal.${sentinel}`);
const signalQuestion = tOrNull(
`insights.coach.seeded.question.${sentinel}`,
);
if (signalLabel && signalQuestion) {
scopeHint = (
<ScopeHintBadge
variant="seeded"
label={signalLabel}
question={signalQuestion}
onSeed={seedComposer}
/>
);
}
}
// v1.18.11 (W11) — the docked composer column: the quiet adopt offer and
// the guided-questions entry card stack above the live composer. Shared by
// the drawer body (via `CoachDrawerBody`) and the page surface (rendered
@@ -566,7 +648,7 @@ export function CoachConversation({
</Button>
</div>
{heroActive ? (
<CoachHero composer={composerNode} />
<CoachHero composer={composerNode} scopeHint={scopeHint} />
) : (
<>
<div className="flex min-h-0 flex-1 flex-col">
@@ -703,7 +785,16 @@ export function CoachConversation({
}
sourcesOpen={sourcesTrayOpen}
onSourcesOpenChange={setSourcesTrayOpen}
sourcesRail={<SourcesRail />}
sourcesRail={
<SourcesRail
// v1.22.0 (A2) — surface the launch scope on the drawer surface;
// only set for a fresh, scoped, not-yet-sent conversation so a
// continued thread (its own established scope) shows no stale line.
activeScopeLabel={
currentConversationId === null ? a2MetricLabel : null
}
/>
}
/>
</div>
);
@@ -27,13 +27,26 @@ import { useTranslations } from "@/lib/i18n/context";
* Motion: one orchestrated reveal — sparkle → greeting → composer stagger
* in via `motion-safe` `animate-in` with capped delays; reduced-motion
* users get the layout instantly.
*
* v1.22.0 (A2 + A3) — the hero accepts an optional `scopeHint` slot
* (a `<ScopeHintBadge>`): the visible "the Coach is already on <metric>"
* pill + seed question when launched scoped (A2), or the pre-seeded
* notable-signal opener when launched unscoped (A3). When absent the hero
* is the calm greeting + composer exactly as before, so the neutral
* blank-state fallback is structural.
*/
export interface CoachHeroProps {
/** The live `<CoachInput>` composer, re-parented into the hero. */
composer: React.ReactNode;
/**
* v1.22.0 — optional scope/opener affordance rendered above the
* composer. The parent owns the data (launch scope / seeded signal) and
* passes a resolved `<ScopeHintBadge>`; null keeps the neutral hero.
*/
scopeHint?: React.ReactNode;
}
export function CoachHero({ composer }: CoachHeroProps) {
export function CoachHero({ composer, scopeHint }: CoachHeroProps) {
const { t } = useTranslations();
return (
@@ -91,6 +104,23 @@ export function CoachHero({ composer }: CoachHeroProps) {
>
{composer}
</div>
{/* v1.22.0 (A2/A3) — visible scope pill + tappable opener, beneath
the composer so the greeting → composer rhythm stays intact and
the opener reads as a follow-on suggestion. Renders only when the
parent resolved a scope or a notable signal. */}
{scopeHint ? (
<div
data-slot="coach-hero-scope-hint"
className={cn(
"w-full",
"motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-bottom-2 motion-safe:duration-500",
)}
style={{ animationDelay: "240ms", animationFillMode: "both" }}
>
{scopeHint}
</div>
) : null}
</div>
</div>
);
@@ -0,0 +1,109 @@
"use client";
import { Sparkles, Target } from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.22.0 (A2 + A3) — the Coach's visible scope/opener affordance.
*
* Two modes, one surface:
*
* - **scope** (A2): the Coach was launched narrowed to a metric (from a
* metric page or an insight card). Instead of the old hidden composer
* prefill, render a small pill — "The Coach is already on <metric>" —
* plus a tappable seed question that drops the data-aware opener into
* the composer / sends it. This turns the built-but-invisible scope
* plumbing into the core ambient feeling.
*
* - **seeded** (A3): the Coach opened UNSCOPED (the blank hero) and the
* server surfaced today's single most notable derived signal. Render it
* as a tappable suggested opener so the blank box is never cold. When no
* signal crosses the gate the caller renders nothing and the neutral
* greeting stands — there is no fake opener.
*
* Presentational + self-contained: the parent supplies the resolved label
* and the seed question, and an `onSeed` callback wired to the composer.
* The badge owns no data fetching.
*/
export interface ScopeHintBadgeProps {
/**
* Which affordance this is. `scope` shows the "already on <metric>" pill;
* `seeded` shows the notable-signal opener. Drives the icon + the prefix
* copy only — the tappable seed question is shared.
*/
variant: "scope" | "seeded";
/**
* The visible metric / signal label, already resolved to the user's
* locale by the caller (e.g. "blood pressure", "readiness").
*/
label: string;
/** The tappable seed question — the data-aware opener. */
question: string;
/** Fired with the seed question when the user taps the opener. */
onSeed: (question: string) => void;
className?: string;
}
export function ScopeHintBadge({
variant,
label,
question,
onSeed,
className,
}: ScopeHintBadgeProps) {
const { t } = useTranslations();
const Icon = variant === "scope" ? Target : Sparkles;
const prefix =
variant === "scope"
? t("insights.coach.scope.prefix", { metric: label })
: t("insights.coach.seeded.prefix", { signal: label });
return (
<div
data-slot="coach-scope-hint"
data-variant={variant}
className={cn("flex w-full flex-col items-center gap-2.5", className)}
>
{/* The scope/signal pill — the visible "already on …" line. */}
<span
data-slot="coach-scope-hint-pill"
className={cn(
"border-border/60 bg-muted/40 text-muted-foreground",
"inline-flex max-w-full items-center gap-1.5 rounded-full border px-3 py-1",
"text-xs font-medium",
)}
>
<Icon
className="text-dracula-purple size-3.5 shrink-0"
aria-hidden="true"
/>
<span className="truncate">{prefix}</span>
</span>
{/* The tappable seed question — the data-aware opener. A real button so
keyboard + screen-reader users get the same affordance. */}
<button
type="button"
data-slot="coach-scope-hint-seed"
onClick={() => onSeed(question)}
className={cn(
"group border-border/70 bg-background hover:bg-muted/50 text-foreground",
"flex w-full max-w-md items-center gap-2 rounded-xl border px-3.5 py-2.5 text-left",
"transition-colors",
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none",
)}
>
<span className="min-w-0 flex-1 text-sm leading-snug">{question}</span>
<span
aria-hidden="true"
className="text-muted-foreground group-hover:text-foreground text-xs whitespace-nowrap"
>
{t("insights.coach.scope.tap")}
</span>
</button>
</div>
);
}
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { Eye, Loader2 } from "lucide-react";
import { Eye, Loader2, Target } from "lucide-react";
import { toast } from "sonner";
import { Switch } from "@/components/ui/switch";
@@ -54,9 +54,19 @@ const WINDOW_OPTIONS: ReadonlyArray<CoachDefaultWindow> = [
export interface SourcesRailProps {
className?: string;
/**
* v1.22.0 (A2) — the human label of the metric the current conversation
* was launched scoped to, or null for a generic (all-source) open. When
* set, the rail surfaces a small "the Coach is already on <metric>" line
* above the persisted-cluster toggles so the active narrowing is VISIBLE
* on the drawer surface (the page surface shows it in the hero). The
* persisted clusters below still describe what the Coach can otherwise
* see — the scope line names where this conversation actually started.
*/
activeScopeLabel?: string | null;
}
export function SourcesRail({ className }: SourcesRailProps) {
export function SourcesRail({ className, activeScopeLabel }: SourcesRailProps) {
const { t } = useTranslations();
const { data: prefs } = useCoachPrefs();
@@ -122,6 +132,25 @@ export function SourcesRail({ className }: SourcesRailProps) {
{t("insights.coach.sourcesTitle")}
</h3>
{/* v1.22.0 (A2) — the visible launch scope for this conversation. Only
rendered when the Coach was opened narrowed to a metric; a generic
open omits it entirely so the rail is unchanged for the default
all-source case. */}
{activeScopeLabel ? (
<div
data-slot="coach-sources-active-scope"
className="border-dracula-purple/30 bg-dracula-purple/5 text-foreground flex items-center gap-1.5 rounded-lg border px-3 py-2 text-xs font-medium"
>
<Target
className="text-dracula-purple size-3.5 shrink-0"
aria-hidden="true"
/>
<span className="min-w-0 truncate">
{t("insights.coach.scope.prefix", { metric: activeScopeLabel })}
</span>
</div>
) : null}
{/* Window selector — persists to `coachPrefs.defaultWindow` so the
chosen timeframe sticks across drawer opens and matches what
the cog sheet shows. */}
+6
View File
@@ -26,4 +26,10 @@ export const coachKeys = {
* the streaming hook invalidates this slot once the persisted twin lands.
*/
coachConversation: (id: string | null) => ["coachConversation", id] as const,
/**
* v1.22.0 (A3) — today's most notable derived signal, resolved into the
* Coach hero's pre-seeded relevance opener
* (`GET /api/insights/coach/seeded-question`).
*/
coachSeededQuestion: () => ["coach-seeded-question"] as const,
};