mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
feat(coach): New conversation in the chat, a foldable list, and the day beside it
New conversation leaves the conversations panel's header and floats in the conversation itself: a round 56 px button with a plus, the floating Coach button's face, named "New conversation". It sits in the composer's row, so it always belongs to the chat column, never to the list or a docked day beside it. Where the column is wide it takes the bottom right corner beside the composer; where the composer fills the width (a phone, the drawer, a narrow column) it rises above the composer's edge. The drawer shows it once there is a conversation to leave, in place of the plus in its header. An empty conversation shows nothing either way. The docked conversations panel folds like the day: its header opens with a fold control, and folded it keeps a 48 px edge whose button opens it again (aria-expanded, focus moved between the two, Escape inside folds it). The top bar's toggle remains for the sheet below 1280 px. On /coach the day docks right of the panel. Below 1600 px only one of the two is open: a day opening folds the list without touching the remembered choice, and opening the list folds the day to its edge. From 1600 px both stay open. Switching or starting a conversation keeps an open day in the URL. The empty conversation drops everything under the composer: the daily seeded opener and the scope pill are gone, and with them the only reader of GET /api/insights/coach/seeded-question and of the coach.ambientSuggestions preference. The route, its OpenAPI entry, the preference and its Settings switch go too.
This commit is contained in:
1 parent
9d5a529d53
commit
08f532b428
29 files changed
+659
-1338
No files matched your search
@@ -11624,30 +11624,6 @@ paths:
|
||||
"403": *a11
|
||||
"422": *a4
|
||||
"429": *a5
|
||||
/api/insights/coach/seeded-question:
|
||||
get:
|
||||
tags:
|
||||
- Insights
|
||||
summary: Today's suggested Coach opener
|
||||
description: "Resolves today's single most notable derived wellness signal into a tappable opener for the Coach's
|
||||
blank-chat hero, using the same confidence-gated detector the daily briefing uses. The selection happens
|
||||
SERVER-side and the client renders it rather than recomputing. `signal: null` is the ordinary answer and means
|
||||
the hero keeps its neutral greeting — never a fabricated opener. Two different causes produce that null and are
|
||||
indistinguishable on the wire: nothing crossed the notability gate, or the account has turned proactive
|
||||
suggestions off, in which case the detector does not run at all. The opener exists only to open the Coach, so it
|
||||
is served only while the `coach` AI capability is available; otherwise the answer is the same neutral `signal:
|
||||
null` and `ai` says why. Never refused for an AI reason. Shared analytics-read budget. Cookie or Bearer auth;
|
||||
not delegable."
|
||||
responses:
|
||||
"200":
|
||||
description: The opener, or the neutral null.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CoachSeededQuestionEnvelope"
|
||||
"401": *a1
|
||||
"422": *a4
|
||||
"429": *a5
|
||||
/api/insights/narrative:
|
||||
get:
|
||||
tags:
|
||||
@@ -27376,8 +27352,6 @@ components:
|
||||
enum:
|
||||
- weekly
|
||||
- biweekly
|
||||
ambientSuggestions:
|
||||
type: boolean
|
||||
nudgeAiComposed:
|
||||
type: boolean
|
||||
measurementReminder:
|
||||
@@ -60734,57 +60708,6 @@ components:
|
||||
description: The two server-authoritative lines a metric sub-page renders above its chart. Pure compute over the
|
||||
baseline and correlation engines — no provider call, no cache table — so web and native decode the same resolved
|
||||
DTO.
|
||||
CoachSeededQuestionEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/CoachSeededQuestion"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
CoachSeededQuestion:
|
||||
type: object
|
||||
properties:
|
||||
signal:
|
||||
anyOf:
|
||||
- type: object
|
||||
properties:
|
||||
sourceMetric:
|
||||
type: string
|
||||
description: Sentinel id the client keys its localised copy on (`readiness` / `recovery`).
|
||||
score:
|
||||
type: number
|
||||
description: The latest 0..100 score.
|
||||
band:
|
||||
type: string
|
||||
description: "`yellow` or `red`. Green never surfaces — a good day is not notable."
|
||||
required:
|
||||
- sourceMetric
|
||||
- score
|
||||
- band
|
||||
additionalProperties: false
|
||||
- type: "null"
|
||||
description: Null whenever nothing crossed the detector's confidence and notability gate, AND whenever the account has
|
||||
turned proactive suggestions off, AND whenever the `coach` capability is unavailable (then `ai` says why) —
|
||||
all mean the hero keeps its neutral greeting rather than showing a fabricated opener.
|
||||
ai:
|
||||
description: The `coach` capability the opener exists for.
|
||||
$ref: "#/components/schemas/AiCapabilityState"
|
||||
required:
|
||||
- signal
|
||||
- ai
|
||||
additionalProperties: false
|
||||
description: Today's single most notable derived wellness signal, resolved server-side into a tappable opener for the
|
||||
Coach's blank-chat hero. The client renders the resolved DTO and never recomputes the selection.
|
||||
NarrativeEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
+196
-125
@@ -4,26 +4,27 @@ import type { Locator, Page, Request, Route, TestInfo } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
|
||||
import { mockDay } from "./setup/day-mock";
|
||||
|
||||
/**
|
||||
* The Coach page frame: the conversations panel on the right, its toggle in
|
||||
* the top bar, the settings gear in the panel header, and New chat opening
|
||||
* that header row.
|
||||
* The Coach page frame: the conversation, the conversations panel right of
|
||||
* it, and the day right of that; New conversation floating in the
|
||||
* conversation itself.
|
||||
*
|
||||
* Contracts under test, at 1440, 1280 and 390 px:
|
||||
* Contracts under test, at 1920, 1440, 1280 and 390 px:
|
||||
*
|
||||
* 1. From 1280 px the panel docks beside the thread, open on a first visit,
|
||||
* and the reading column (thread and composer) is exactly as wide with
|
||||
* the panel open as with it closed. The choice survives a reload. The
|
||||
* panel is flush with the viewport's right edge (no scrollbar gutter
|
||||
* beside it) and runs from the top of the window to the bottom; the
|
||||
* top bar ends at its left edge and the panel's header border is the
|
||||
* top bar's. The toggle is the bar's last item, within 16 px of its
|
||||
* right edge, open and shut; shut, no pixel of the panel shows and the
|
||||
* bar runs to the window's edge. New chat is the header row's first
|
||||
* control, left of the title, and never overlaps the list.
|
||||
* 2. Escape inside the docked panel closes it and returns focus to the
|
||||
* toggle.
|
||||
* the panel open as folded. Folded, the panel keeps a 48 px edge whose
|
||||
* button (in the place of the open panel's fold control) opens it
|
||||
* again; the choice survives a reload. The panel is flush with the
|
||||
* viewport's right edge and runs from the top of the window to the
|
||||
* bottom; the top bar ends at its left edge and the panel's header
|
||||
* border is the top bar's. Focus moves between the fold control and
|
||||
* the edge's button, and Escape inside the open panel folds it.
|
||||
* 2. New conversation is a round 56 px button in the conversation column,
|
||||
* never over the panel, never over the composer's field, and absent
|
||||
* from the panel's header.
|
||||
* 3. A row's menu renames by keyboard (Escape cancels without closing the
|
||||
* panel, Enter saves once) and deletes with an Undo that keeps the
|
||||
* DELETE from ever leaving; a delete left alone commits once.
|
||||
@@ -32,10 +33,13 @@ import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
|
||||
* can see" and drops the param from the URL.
|
||||
* 5. axe finds nothing on the open panel or the open settings, in the
|
||||
* light and the dark theme.
|
||||
* 6. At 390 px the panel is a dialog sheet with New chat first in its
|
||||
* header and the title beside it; Escape closes it and focus returns to the toggle. The gear
|
||||
* opens a bottom sheet that closes on Escape back to the gear. Nothing
|
||||
* scrolls sideways.
|
||||
* 6. The day docks right of the panel. At 1440 only one of the two is
|
||||
* open: a day folds the panel, opening the panel folds the day. At 1920
|
||||
* both stay open and the conversation keeps at least 560 px.
|
||||
* 7. At 390 px the panel is a dialog sheet opened from the top bar's
|
||||
* toggle; Escape closes it and focus returns to the toggle. The gear
|
||||
* opens a bottom sheet that closes on Escape back to the gear. New
|
||||
* conversation sits above the composer. Nothing scrolls sideways.
|
||||
*
|
||||
* Every Coach route is stubbed; the provider chain and provider config are
|
||||
* stubbed too, so the model section renders the same on every machine.
|
||||
@@ -174,9 +178,6 @@ async function mockCoach(page: Page): Promise<CoachMock> {
|
||||
await page.route("**/api/coach/about-me/questions*", (route) =>
|
||||
fulfilJson(route, { questions: [] }),
|
||||
);
|
||||
await page.route("**/api/insights/coach/seeded-question*", (route) =>
|
||||
fulfilJson(route, { signal: null }),
|
||||
);
|
||||
await page.route("**/api/insights/provider-chain", (route) =>
|
||||
route.request().method() === "GET"
|
||||
? fulfilJson(route, {
|
||||
@@ -213,6 +214,11 @@ async function mockCoach(page: Page): Promise<CoachMock> {
|
||||
}
|
||||
|
||||
const toggle = (page: Page) => page.locator('[data-slot="coach-panel-toggle"]');
|
||||
const collapse = (page: Page) =>
|
||||
page.locator('[data-slot="coach-panel-collapse"]');
|
||||
const expand = (page: Page) => page.locator('[data-slot="coach-panel-expand"]');
|
||||
const fab = (page: Page) => page.locator('[data-slot="coach-new-chat-fab"]');
|
||||
const dayPanel = (page: Page) => page.locator('[data-slot="day-panel"]');
|
||||
const panel = (page: Page) =>
|
||||
page.locator('[data-slot="coach-conversations-panel"]');
|
||||
|
||||
@@ -252,9 +258,10 @@ async function expectPanelFlush(page: Page, width: number, height: number) {
|
||||
'[data-slot="coach-conversations-panel"]',
|
||||
)!;
|
||||
const inner = aside.firstElementChild as HTMLElement;
|
||||
const header = aside.querySelector(
|
||||
'[data-slot="coach-conversations-panel-header"]',
|
||||
)!;
|
||||
// Open, the header row; folded, the edge's band.
|
||||
const header =
|
||||
aside.querySelector('[data-slot="coach-conversations-panel-header"]') ??
|
||||
aside.querySelector('[data-slot="coach-panel-expand"]')!.parentElement!;
|
||||
const bar = document.querySelector('[data-slot="top-bar"]')!;
|
||||
const main = document.getElementById("main-content")!;
|
||||
const style = getComputedStyle(inner);
|
||||
@@ -287,68 +294,37 @@ async function expectPanelFlush(page: Page, width: number, height: number) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The toggle is the last item of the top bar (the account menu that follows
|
||||
* it on phones is not rendered from `md`), and its right edge is within
|
||||
* 16 px of the bar's right edge.
|
||||
* New conversation floats in the conversation column: a round 56 px button
|
||||
* with a plus and its name, inside the column (never over a docked panel
|
||||
* right of it) and clear of the composer's field. The panel's header has no
|
||||
* such control.
|
||||
*/
|
||||
async function expectToggleAtBarEnd(page: Page) {
|
||||
const at = await page.evaluate(() => {
|
||||
const bar = document.querySelector('[data-slot="top-bar"]')!;
|
||||
const shown = [...bar.querySelectorAll("*")].filter(
|
||||
(el) => el.getClientRects().length > 0,
|
||||
);
|
||||
const toggle = bar.querySelector('[data-slot="coach-panel-toggle"]')!;
|
||||
const rightmost = Math.max(
|
||||
...shown.map((el) => el.getBoundingClientRect().right),
|
||||
);
|
||||
return {
|
||||
gap:
|
||||
bar.getBoundingClientRect().right -
|
||||
toggle.getBoundingClientRect().right,
|
||||
last: toggle.getBoundingClientRect().right >= rightmost,
|
||||
lastChild:
|
||||
toggle.closest('[data-slot="top-bar-actions"]')?.lastElementChild ===
|
||||
toggle,
|
||||
};
|
||||
});
|
||||
expect(at.last, "nothing in the bar stands right of the toggle").toBe(true);
|
||||
expect(at.lastChild).toBe(true);
|
||||
expect(at.gap).toBeGreaterThanOrEqual(0);
|
||||
expect(at.gap).toBeLessThanOrEqual(16);
|
||||
}
|
||||
|
||||
/**
|
||||
* New chat opens the panel header: the row's first control, left of the
|
||||
* title, a plus glyph with an accessible name. It sits above the list, so no
|
||||
* row ever slides under it.
|
||||
*/
|
||||
async function expectNewChatInHeader(scope: Locator) {
|
||||
const header = scope.locator(
|
||||
'[data-slot="coach-conversations-panel-header"]',
|
||||
);
|
||||
const button = header.locator('[data-slot="coach-panel-new-chat"]');
|
||||
await expect(button).toHaveCount(1);
|
||||
await expect(button.locator("svg.lucide-plus")).toHaveCount(1);
|
||||
await expect(button).toHaveAttribute("aria-label", "New chat");
|
||||
const order = await header.evaluate((el) => {
|
||||
const first = el.firstElementChild as HTMLElement;
|
||||
const heading = el.querySelector("h2") as HTMLElement;
|
||||
return {
|
||||
first: first.dataset.slot,
|
||||
buttonRight: first.getBoundingClientRect().right,
|
||||
headingLeft: heading.getBoundingClientRect().left,
|
||||
};
|
||||
});
|
||||
expect(order.first).toBe("coach-panel-new-chat");
|
||||
expect(order.buttonRight).toBeLessThanOrEqual(order.headingLeft);
|
||||
const buttonBox = (await button.boundingBox())!;
|
||||
const listBox = (await scope
|
||||
.locator('[data-slot="coach-history-list"]')
|
||||
async function expectNewChatFab(page: Page) {
|
||||
await expect(fab(page)).toHaveCount(1);
|
||||
await expect(fab(page)).toHaveAttribute("aria-label", "New conversation");
|
||||
await expect(fab(page).locator("svg.lucide-plus")).toHaveCount(1);
|
||||
await expect(
|
||||
panel(page).locator('[data-slot="coach-panel-new-chat"]'),
|
||||
).toHaveCount(0);
|
||||
const box = (await fab(page).boundingBox())!;
|
||||
expect(Math.round(box.width)).toBe(56);
|
||||
expect(Math.round(box.height)).toBe(56);
|
||||
const main = (await page
|
||||
.locator('[data-slot="coach-page-main"]')
|
||||
.boundingBox())!;
|
||||
expect(
|
||||
buttonBox.y + buttonBox.height,
|
||||
"the button ends above the list",
|
||||
).toBeLessThanOrEqual(listBox.y);
|
||||
expect(box.x).toBeGreaterThanOrEqual(main.x);
|
||||
expect(box.x + box.width).toBeLessThanOrEqual(main.x + main.width);
|
||||
const field = (await page
|
||||
.locator(
|
||||
'[data-slot="coach-page-composer"] [data-slot="coach-input-textarea"]',
|
||||
)
|
||||
.boundingBox())!;
|
||||
const overlaps =
|
||||
box.x < field.x + field.width &&
|
||||
box.x + box.width > field.x &&
|
||||
box.y < field.y + field.height &&
|
||||
box.y + box.height > field.y;
|
||||
expect(overlaps, "the button leaves the composer's field free").toBe(false);
|
||||
}
|
||||
|
||||
/** Rendered heights of the docked panel's header buttons and first row. */
|
||||
@@ -361,7 +337,7 @@ async function panelControlHeights(page: Page) {
|
||||
.boundingBox())!.height,
|
||||
);
|
||||
return {
|
||||
newChat: await height("coach-panel-new-chat"),
|
||||
collapse: await height("coach-panel-collapse"),
|
||||
plans: await height("coach-panel-plans"),
|
||||
gear: await height("coach-settings"),
|
||||
row: await height("coach-history-select"),
|
||||
@@ -446,8 +422,10 @@ test.describe("Coach page frame", () => {
|
||||
// First visit: open.
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "open");
|
||||
await waitForWidth(panel(page), 288);
|
||||
await expect(toggle(page)).toHaveAttribute("aria-expanded", "true");
|
||||
await expect(toggle(page)).toHaveAttribute(
|
||||
// Docked, the fold control lives in the panel; the top bar has none.
|
||||
await expect(toggle(page)).toHaveCount(0);
|
||||
await expect(collapse(page)).toHaveAttribute("aria-expanded", "true");
|
||||
await expect(collapse(page)).toHaveAttribute(
|
||||
"aria-controls",
|
||||
"coach-conversations-panel",
|
||||
);
|
||||
@@ -458,9 +436,7 @@ test.describe("Coach page frame", () => {
|
||||
const aside = (await panel(page).boundingBox())!;
|
||||
expect(aside.x).toBeGreaterThanOrEqual(open.threadX + open.thread);
|
||||
await expectPanelFlush(page, width, 900);
|
||||
// Open, the toggle is the top bar's last item, against the panel.
|
||||
await expectToggleAtBarEnd(page);
|
||||
await expectNewChatInHeader(panel(page));
|
||||
await expectNewChatFab(page);
|
||||
// The bar says where the reader is: the page, then the conversation.
|
||||
await expect(
|
||||
page.locator('[data-slot="coach-top-bar-trail"]'),
|
||||
@@ -468,37 +444,25 @@ test.describe("Coach page frame", () => {
|
||||
await expectNoSidewaysScroll(page);
|
||||
await shot(page, testInfo, `coach-frame-${width}-open`);
|
||||
|
||||
await toggle(page).click();
|
||||
// Folded: a 48 px edge stays, its button takes the focus, and the
|
||||
// top bar ends at the edge.
|
||||
await collapse(page).click();
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "closed");
|
||||
await waitForWidth(panel(page), 0);
|
||||
await expect(toggle(page)).toHaveAttribute("aria-expanded", "false");
|
||||
await waitForWidth(panel(page), 48);
|
||||
await expect(expand(page)).toBeFocused();
|
||||
await expect(expand(page)).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(expand(page)).toHaveAttribute(
|
||||
"aria-label",
|
||||
"Show conversations",
|
||||
);
|
||||
await expect(
|
||||
panel(page).locator('[data-slot="coach-history-select"]'),
|
||||
).toHaveCount(0);
|
||||
await expectPanelFlush(page, width, 900);
|
||||
const closed = await columnWidths(page);
|
||||
// Closed, the panel is entirely off-screen, the top bar runs to the
|
||||
// window's edge and the toggle is still its last item.
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
document
|
||||
.querySelector('[data-slot="top-bar"]')!
|
||||
.getBoundingClientRect().right,
|
||||
),
|
||||
)
|
||||
.toBe(width);
|
||||
expect(
|
||||
await panel(page).evaluate((el) => {
|
||||
const box = el.getBoundingClientRect();
|
||||
return box.width === 0 || box.left >= window.innerWidth;
|
||||
}),
|
||||
"no pixel of the shut panel shows",
|
||||
).toBe(true);
|
||||
await expectToggleAtBarEnd(page);
|
||||
expect(Math.abs(closed.thread - open.thread)).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(closed.composer - open.composer)).toBeLessThanOrEqual(1);
|
||||
// Closed, nothing inside the panel is reachable.
|
||||
await expect(
|
||||
panel(page).locator('[data-slot="coach-history-select"]').first(),
|
||||
).not.toBeInViewport();
|
||||
await expectNewChatFab(page);
|
||||
await expectNoSidewaysScroll(page);
|
||||
await shot(page, testInfo, `coach-frame-${width}-closed`);
|
||||
|
||||
@@ -506,19 +470,118 @@ test.describe("Coach page frame", () => {
|
||||
await openCoach(page);
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "closed");
|
||||
|
||||
// Open again, then Escape from inside closes it, focus to the toggle.
|
||||
await toggle(page).click();
|
||||
// Opened from the keyboard, focus lands on the fold control; Escape
|
||||
// from inside folds it again and focus goes back to the edge.
|
||||
await expand(page).focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "open");
|
||||
await expect(collapse(page)).toBeFocused();
|
||||
await panel(page)
|
||||
.locator('[data-slot="coach-history-select"]')
|
||||
.first()
|
||||
.focus();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "closed");
|
||||
await expect(toggle(page)).toBeFocused();
|
||||
await expect(expand(page)).toBeFocused();
|
||||
});
|
||||
}
|
||||
|
||||
test("1440: the day and the conversations take turns, focus included", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await mockCoach(page);
|
||||
await mockDay(page);
|
||||
const day = "2026-06-01";
|
||||
await page.goto(`/coach?c=frame-bp&day=${day}`, {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await expect(
|
||||
page.locator('[data-slot="coach-message-thread"]').getByText(ANSWER),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// The day opens docked, right of the conversations, which fold to
|
||||
// their edge for it.
|
||||
await expect(dayPanel(page)).toHaveAttribute("data-shell", "docked");
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "closed");
|
||||
await waitForWidth(panel(page), 48);
|
||||
const rail = (await panel(page).boundingBox())!;
|
||||
const dayBox = (await dayPanel(page).boundingBox())!;
|
||||
expect(rail.x + rail.width).toBeLessThanOrEqual(dayBox.x + 1);
|
||||
expect(Math.round(dayBox.x + dayBox.width)).toBe(1440);
|
||||
await expectNewChatFab(page);
|
||||
expect(
|
||||
(await page.locator('[data-slot="coach-page-main"]').boundingBox())!
|
||||
.width,
|
||||
).toBeGreaterThanOrEqual(540);
|
||||
await expectNoSidewaysScroll(page);
|
||||
await shot(page, testInfo, "coach-frame-1440-day");
|
||||
|
||||
// Opening the conversations folds the day to its edge; focus goes to
|
||||
// the conversations' fold control, the day is out of the URL, and the
|
||||
// conversation stays the same.
|
||||
await expand(page).click();
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "open");
|
||||
await expect(dayPanel(page)).toHaveCount(0);
|
||||
await expect(page.locator('[data-slot="day-rail"]')).toHaveAttribute(
|
||||
"data-day",
|
||||
day,
|
||||
);
|
||||
await expect(collapse(page)).toBeFocused();
|
||||
await expect(page).toHaveURL(/\/coach\?c=frame-bp$/);
|
||||
await expectNewChatFab(page);
|
||||
await shot(page, testInfo, "coach-frame-1440-list");
|
||||
|
||||
// And back: the day's edge opens the day, the conversations fold.
|
||||
await page.locator('[data-slot="day-expand"]').click();
|
||||
await expect(dayPanel(page)).toHaveAttribute("data-shell", "docked");
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "closed");
|
||||
await expect(page).toHaveURL(new RegExp(`c=frame-bp&day=${day}$`));
|
||||
|
||||
// Switching conversations keeps the day open.
|
||||
await expand(page).click();
|
||||
await page.locator('[data-slot="day-expand"]').click();
|
||||
await expect(dayPanel(page)).toBeVisible();
|
||||
await fab(page).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/coach\\?day=${day}$`));
|
||||
await expect(dayPanel(page)).toBeVisible();
|
||||
});
|
||||
|
||||
test("1920: the conversations and the day stay open side by side", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await page.setViewportSize({ width: 1920, height: 1000 });
|
||||
await mockCoach(page);
|
||||
await mockDay(page);
|
||||
await page.goto("/coach?c=frame-bp&day=2026-06-01", {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await expect(
|
||||
page.locator('[data-slot="coach-message-thread"]').getByText(ANSWER),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
await expect(dayPanel(page)).toHaveAttribute("data-shell", "docked");
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "open");
|
||||
await waitForWidth(panel(page), 288);
|
||||
const list = (await panel(page).boundingBox())!;
|
||||
const dayBox = (await dayPanel(page).boundingBox())!;
|
||||
expect(list.x + list.width).toBeLessThanOrEqual(dayBox.x + 1);
|
||||
expect(
|
||||
(await page.locator('[data-slot="coach-page-main"]').boundingBox())!
|
||||
.width,
|
||||
).toBeGreaterThanOrEqual(560);
|
||||
await expectNewChatFab(page);
|
||||
await expectNoSidewaysScroll(page);
|
||||
await shot(page, testInfo, "coach-frame-1920-both");
|
||||
|
||||
// Folding one leaves the other alone.
|
||||
await collapse(page).click();
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "closed");
|
||||
await expect(dayPanel(page)).toBeVisible();
|
||||
await expand(page).click();
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "open");
|
||||
await expect(dayPanel(page)).toBeVisible();
|
||||
});
|
||||
|
||||
test("1280 fine pointer: compact header buttons and rows", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -526,7 +589,7 @@ test.describe("Coach page frame", () => {
|
||||
await mockCoach(page);
|
||||
await openCoach(page);
|
||||
expect(await panelControlHeights(page)).toEqual({
|
||||
newChat: 28,
|
||||
collapse: 28,
|
||||
plans: 28,
|
||||
gear: 28,
|
||||
row: 36,
|
||||
@@ -546,7 +609,7 @@ test.describe("Coach page frame", () => {
|
||||
await openCoach(page);
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "open");
|
||||
expect(await panelControlHeights(page)).toEqual({
|
||||
newChat: 44,
|
||||
collapse: 44,
|
||||
plans: 44,
|
||||
gear: 44,
|
||||
row: 44,
|
||||
@@ -575,8 +638,7 @@ test.describe("Coach page frame", () => {
|
||||
await expect(page).toHaveURL(/\/coach\?c=frame-sleep$/);
|
||||
await expect(rows.nth(1)).toHaveAttribute("aria-current", "true");
|
||||
|
||||
const newChat = panel(page).locator('[data-slot="coach-panel-new-chat"]');
|
||||
await newChat.click();
|
||||
await fab(page).click();
|
||||
await expect(page).toHaveURL(/\/coach$/);
|
||||
await expect(
|
||||
page.locator('[data-slot="coach-input-textarea"]'),
|
||||
@@ -795,6 +857,13 @@ test.describe("Coach page frame", () => {
|
||||
// Below 1280 px the panel waits to be asked.
|
||||
await expect(panel(page)).toHaveCount(0);
|
||||
await expect(toggle(page)).toHaveAttribute("aria-expanded", "false");
|
||||
// New conversation sits above the composer, clear of its field.
|
||||
await expectNewChatFab(page);
|
||||
const fabBox = (await fab(page).boundingBox())!;
|
||||
const composerBox = (await page
|
||||
.locator('[data-slot="coach-page-composer"]')
|
||||
.boundingBox())!;
|
||||
expect(fabBox.y + fabBox.height).toBeLessThanOrEqual(composerBox.y);
|
||||
await expectNoSidewaysScroll(page);
|
||||
await shot(page, testInfo, "coach-frame-390-thread");
|
||||
|
||||
@@ -802,7 +871,9 @@ test.describe("Coach page frame", () => {
|
||||
const sheet = page.getByRole("dialog", { name: "Conversations" });
|
||||
await expect(sheet).toBeVisible();
|
||||
await expect(toggle(page)).toHaveAttribute("aria-expanded", "true");
|
||||
await expectNewChatInHeader(sheet);
|
||||
await expect(
|
||||
sheet.locator('[data-slot="coach-panel-new-chat"]'),
|
||||
).toHaveCount(0);
|
||||
// The title keeps its whole word beside four header controls.
|
||||
const title = sheet.locator(
|
||||
'[data-slot="coach-conversations-panel-header"] h2',
|
||||
|
||||
+2
-18
@@ -2851,6 +2851,7 @@
|
||||
"errorCredentialExpired": "Deine Verbindung zum KI-Anbieter ist abgelaufen. Stelle sie in den Einstellungen wieder her, damit der Coach weiterläuft.",
|
||||
"tagline": "Persönlicher Gesundheitscoach, auf deine Daten gestützt.",
|
||||
"newChat": "Neuer Chat",
|
||||
"newConversation": "Neue Unterhaltung",
|
||||
"docScope": {
|
||||
"badge": "Dokument",
|
||||
"chattingAbout": "Chat über: {title}",
|
||||
@@ -3092,21 +3093,7 @@
|
||||
"driver": "{note}"
|
||||
},
|
||||
"scope": {
|
||||
"prefix": "Der Coach ist schon bei {metric}",
|
||||
"tap": "Zum Fragen tippen",
|
||||
"question": "Erkläre mir diese Kennzahl — worauf sollte ich achten?"
|
||||
},
|
||||
"seeded": {
|
||||
"prefix": "Heute einen Blick wert: {signal}",
|
||||
"signal": {
|
||||
"readiness": "Bereitschaft",
|
||||
"recovery": "Erholung"
|
||||
},
|
||||
"question": {
|
||||
"readiness": "Meine Bereitschaft ist heute auffällig — woran liegt das?",
|
||||
"recovery": "Meine Erholung hat sich verändert — was steckt dahinter?"
|
||||
},
|
||||
"dismiss": "Ausblenden"
|
||||
"prefix": "Der Coach ist schon bei {metric}"
|
||||
},
|
||||
"settings": "Einstellungen",
|
||||
"history": {
|
||||
@@ -8371,9 +8358,6 @@
|
||||
"enabledToast": "Proaktive Coach-Hinweise aktiviert.",
|
||||
"disabledToast": "Proaktive Coach-Hinweise deaktiviert.",
|
||||
"saveError": "Speichern fehlgeschlagen. Bitte später erneut versuchen.",
|
||||
"suggestionsLabel": "Beispielvorschläge",
|
||||
"suggestionsDesc": "Zeigt auf dem Coach-Bildschirm gelegentlich eine fertige Beispielfrage.",
|
||||
"suggestionsAria": "Beispiel-Coach-Vorschläge umschalten",
|
||||
"aiComposedLabel": "Den Coach den Anstoß formulieren lassen",
|
||||
"aiComposedDesc": "Wenn ein Anbieter verfügbar ist, formuliert das Modell den proaktiven Anstoß statt der festen Vorlage. Standardmäßig aus.",
|
||||
"aiComposedAria": "KI-formulierten proaktiven Anstoß umschalten"
|
||||
|
||||
+2
-18
@@ -2851,6 +2851,7 @@
|
||||
"errorCredentialExpired": "Your AI provider connection has expired. Reconnect it in Settings to keep the Coach running.",
|
||||
"tagline": "Personal health coach, grounded in your data.",
|
||||
"newChat": "New chat",
|
||||
"newConversation": "New conversation",
|
||||
"docScope": {
|
||||
"badge": "Document",
|
||||
"chattingAbout": "Chatting about: {title}",
|
||||
@@ -3092,21 +3093,7 @@
|
||||
"driver": "{note}"
|
||||
},
|
||||
"scope": {
|
||||
"prefix": "The Coach is already on {metric}",
|
||||
"tap": "Tap to ask",
|
||||
"question": "Walk me through this metric — anything I should watch?"
|
||||
},
|
||||
"seeded": {
|
||||
"prefix": "Worth a look today: {signal}",
|
||||
"signal": {
|
||||
"readiness": "readiness",
|
||||
"recovery": "recovery"
|
||||
},
|
||||
"question": {
|
||||
"readiness": "My readiness is off today — what's driving it?",
|
||||
"recovery": "My recovery shifted — what's behind it?"
|
||||
},
|
||||
"dismiss": "Dismiss"
|
||||
"prefix": "The Coach is already on {metric}"
|
||||
},
|
||||
"settings": "Settings",
|
||||
"history": {
|
||||
@@ -8371,9 +8358,6 @@
|
||||
"enabledToast": "Proactive Coach nudges enabled.",
|
||||
"disabledToast": "Proactive Coach nudges disabled.",
|
||||
"saveError": "Could not save. Please try again later.",
|
||||
"suggestionsLabel": "Example suggestions",
|
||||
"suggestionsDesc": "Show the occasional ready-made example question on the Coach screen.",
|
||||
"suggestionsAria": "Toggle example Coach suggestions",
|
||||
"aiComposedLabel": "Let the Coach phrase the nudge",
|
||||
"aiComposedDesc": "When a provider is available, the proactive nudge is written by the model instead of the fixed template. Off by default.",
|
||||
"aiComposedAria": "Toggle AI-composed proactive nudge"
|
||||
|
||||
+2
-18
@@ -2851,6 +2851,7 @@
|
||||
"errorCredentialExpired": "Tu conexión con el proveedor de IA ha caducado. Vuelve a conectarla en Ajustes para que el Coach siga funcionando.",
|
||||
"tagline": "Coach personal de salud, apoyado en tus datos.",
|
||||
"newChat": "Nueva conversación",
|
||||
"newConversation": "Nueva conversación",
|
||||
"docScope": {
|
||||
"badge": "Documento",
|
||||
"chattingAbout": "Hablando sobre: {title}",
|
||||
@@ -3092,21 +3093,7 @@
|
||||
"driver": "{note}"
|
||||
},
|
||||
"scope": {
|
||||
"prefix": "El Coach ya está en {metric}",
|
||||
"tap": "Toca para preguntar",
|
||||
"question": "Explícame esta métrica — ¿hay algo que deba vigilar?"
|
||||
},
|
||||
"seeded": {
|
||||
"prefix": "Hoy merece un vistazo: {signal}",
|
||||
"signal": {
|
||||
"readiness": "disposición",
|
||||
"recovery": "recuperación"
|
||||
},
|
||||
"question": {
|
||||
"readiness": "Mi disposición está rara hoy — ¿a qué se debe?",
|
||||
"recovery": "Mi recuperación ha cambiado — ¿qué hay detrás?"
|
||||
},
|
||||
"dismiss": "Descartar"
|
||||
"prefix": "El Coach ya está en {metric}"
|
||||
},
|
||||
"settings": "Ajustes",
|
||||
"history": {
|
||||
@@ -8371,9 +8358,6 @@
|
||||
"enabledToast": "Avisos proactivos del Coach activados.",
|
||||
"disabledToast": "Avisos proactivos del Coach desactivados.",
|
||||
"saveError": "No se pudo guardar. Inténtalo de nuevo más tarde.",
|
||||
"suggestionsLabel": "Sugerencias de ejemplo",
|
||||
"suggestionsDesc": "Muestra de vez en cuando una pregunta de ejemplo en la pantalla del Coach.",
|
||||
"suggestionsAria": "Activar o desactivar las sugerencias de ejemplo del Coach",
|
||||
"aiComposedLabel": "Deja que el Coach redacte el aviso",
|
||||
"aiComposedDesc": "Cuando hay un proveedor disponible, el aviso proactivo lo redacta el modelo en lugar de la plantilla fija. Desactivado por defecto.",
|
||||
"aiComposedAria": "Activar o desactivar el aviso proactivo redactado por IA"
|
||||
|
||||
+2
-18
@@ -2851,6 +2851,7 @@
|
||||
"errorCredentialExpired": "Ta connexion au fournisseur d'IA a expiré. Reconnecte-la dans les Réglages pour que le Coach continue de fonctionner.",
|
||||
"tagline": "Coach personnel de santé, ancré sur tes données.",
|
||||
"newChat": "Nouvelle conversation",
|
||||
"newConversation": "Nouvelle conversation",
|
||||
"docScope": {
|
||||
"badge": "Document",
|
||||
"chattingAbout": "Discussion sur : {title}",
|
||||
@@ -3092,21 +3093,7 @@
|
||||
"driver": "{note}"
|
||||
},
|
||||
"scope": {
|
||||
"prefix": "Le Coach est déjà sur {metric}",
|
||||
"tap": "Touche pour demander",
|
||||
"question": "Explique-moi cette mesure — y a-t-il quelque chose à surveiller ?"
|
||||
},
|
||||
"seeded": {
|
||||
"prefix": "À regarder aujourd'hui : {signal}",
|
||||
"signal": {
|
||||
"readiness": "disponibilité",
|
||||
"recovery": "récupération"
|
||||
},
|
||||
"question": {
|
||||
"readiness": "Ma disponibilité est inhabituelle aujourd'hui — qu'est-ce qui l'explique ?",
|
||||
"recovery": "Ma récupération a changé — qu'est-ce qui se cache derrière ?"
|
||||
},
|
||||
"dismiss": "Ignorer"
|
||||
"prefix": "Le Coach est déjà sur {metric}"
|
||||
},
|
||||
"settings": "Paramètres",
|
||||
"history": {
|
||||
@@ -8371,9 +8358,6 @@
|
||||
"enabledToast": "Rappels proactifs du Coach activés.",
|
||||
"disabledToast": "Rappels proactifs du Coach désactivés.",
|
||||
"saveError": "Impossible d'enregistrer. Veuillez réessayer plus tard.",
|
||||
"suggestionsLabel": "Suggestions d'exemples",
|
||||
"suggestionsDesc": "Affiche de temps en temps une question d'exemple prête à l'emploi sur l'écran du Coach.",
|
||||
"suggestionsAria": "Activer ou désactiver les suggestions d'exemples du Coach",
|
||||
"aiComposedLabel": "Laisser le Coach rédiger le message",
|
||||
"aiComposedDesc": "Quand un fournisseur est disponible, le message proactif est rédigé par le modèle plutôt que par le modèle de texte fixe. Désactivé par défaut.",
|
||||
"aiComposedAria": "Activer ou désactiver le message proactif rédigé par l'IA"
|
||||
|
||||
+2
-18
@@ -2851,6 +2851,7 @@
|
||||
"errorCredentialExpired": "La connessione al provider di IA è scaduta. Riconnettila nelle Impostazioni per mantenere attivo il Coach.",
|
||||
"tagline": "Coach personale di salute, basato sui tuoi dati.",
|
||||
"newChat": "Nuova chat",
|
||||
"newConversation": "Nuova conversazione",
|
||||
"docScope": {
|
||||
"badge": "Documento",
|
||||
"chattingAbout": "Stai parlando di: {title}",
|
||||
@@ -3092,21 +3093,7 @@
|
||||
"driver": "{note}"
|
||||
},
|
||||
"scope": {
|
||||
"prefix": "Il Coach è già su {metric}",
|
||||
"tap": "Tocca per chiedere",
|
||||
"question": "Spiegami questo parametro — c'è qualcosa da tenere d'occhio?"
|
||||
},
|
||||
"seeded": {
|
||||
"prefix": "Oggi vale uno sguardo: {signal}",
|
||||
"signal": {
|
||||
"readiness": "prontezza",
|
||||
"recovery": "recupero"
|
||||
},
|
||||
"question": {
|
||||
"readiness": "La mia prontezza è anomala oggi — da cosa dipende?",
|
||||
"recovery": "Il mio recupero è cambiato — cosa c'è dietro?"
|
||||
},
|
||||
"dismiss": "Ignora"
|
||||
"prefix": "Il Coach è già su {metric}"
|
||||
},
|
||||
"settings": "Impostazioni",
|
||||
"history": {
|
||||
@@ -8371,9 +8358,6 @@
|
||||
"enabledToast": "Avvisi proattivi del Coach attivati.",
|
||||
"disabledToast": "Avvisi proattivi del Coach disattivati.",
|
||||
"saveError": "Impossibile salvare. Riprova più tardi.",
|
||||
"suggestionsLabel": "Suggerimenti di esempio",
|
||||
"suggestionsDesc": "Mostra di tanto in tanto una domanda di esempio pronta all'uso nella schermata del Coach.",
|
||||
"suggestionsAria": "Attiva o disattiva i suggerimenti di esempio del Coach",
|
||||
"aiComposedLabel": "Lascia che sia il Coach a formulare il promemoria",
|
||||
"aiComposedDesc": "Quando è disponibile un provider, il promemoria proattivo viene scritto dal modello anziché dal modello di testo fisso. Disattivato per impostazione predefinita.",
|
||||
"aiComposedAria": "Attiva o disattiva il promemoria proattivo scritto dall'IA"
|
||||
|
||||
+2
-18
@@ -2851,6 +2851,7 @@
|
||||
"errorCredentialExpired": "AI 제공자 연결이 만료됐어요. 코치를 계속 쓰려면 설정에서 다시 연결해 주세요.",
|
||||
"tagline": "내 데이터에 기반한 개인 건강 코치.",
|
||||
"newChat": "새 대화",
|
||||
"newConversation": "새 대화",
|
||||
"docScope": {
|
||||
"badge": "문서",
|
||||
"chattingAbout": "대화 중인 문서: {title}",
|
||||
@@ -3092,21 +3093,7 @@
|
||||
"driver": "{note}"
|
||||
},
|
||||
"scope": {
|
||||
"prefix": "코치가 이미 보고 있어요: {metric}",
|
||||
"tap": "탭해서 물어보기",
|
||||
"question": "이 지표를 설명해 주세요 — 눈여겨볼 게 있나요?"
|
||||
},
|
||||
"seeded": {
|
||||
"prefix": "오늘 살펴볼 만한 것: {signal}",
|
||||
"signal": {
|
||||
"readiness": "준비도",
|
||||
"recovery": "회복"
|
||||
},
|
||||
"question": {
|
||||
"readiness": "오늘 준비도가 평소와 달라요 — 무엇 때문일까요?",
|
||||
"recovery": "회복 상태가 달라졌어요 — 이유가 뭘까요?"
|
||||
},
|
||||
"dismiss": "닫기"
|
||||
"prefix": "코치가 이미 보고 있어요: {metric}"
|
||||
},
|
||||
"settings": "설정",
|
||||
"history": {
|
||||
@@ -8371,9 +8358,6 @@
|
||||
"enabledToast": "능동 코치 알림을 켰어요.",
|
||||
"disabledToast": "능동 코치 알림을 껐어요.",
|
||||
"saveError": "저장하지 못했어요. 나중에 다시 시도해 주세요.",
|
||||
"suggestionsLabel": "예시 질문",
|
||||
"suggestionsDesc": "코치 화면에 미리 준비된 예시 질문을 가끔 보여줘요.",
|
||||
"suggestionsAria": "코치 예시 질문 켜고 끄기",
|
||||
"aiComposedLabel": "코치가 알림 문구 작성",
|
||||
"aiComposedDesc": "제공자를 쓸 수 있으면 고정 템플릿 대신 모델이 알림 문구를 써요. 기본값은 꺼짐이에요.",
|
||||
"aiComposedAria": "AI가 쓴 능동 알림 켜고 끄기"
|
||||
|
||||
+2
-18
@@ -2851,6 +2851,7 @@
|
||||
"errorCredentialExpired": "Połączenie z dostawcą AI wygasło. Połącz je ponownie w Ustawieniach, aby Coach działał dalej.",
|
||||
"tagline": "Osobisty coach zdrowia, oparty na twoich danych.",
|
||||
"newChat": "Nowa rozmowa",
|
||||
"newConversation": "Nowa rozmowa",
|
||||
"docScope": {
|
||||
"badge": "Dokument",
|
||||
"chattingAbout": "Rozmowa o: {title}",
|
||||
@@ -3092,21 +3093,7 @@
|
||||
"driver": "{note}"
|
||||
},
|
||||
"scope": {
|
||||
"prefix": "Coach jest już przy {metric}",
|
||||
"tap": "Dotknij, aby zapytać",
|
||||
"question": "Wytłumacz mi tę wartość — na co powinienem zwrócić uwagę?"
|
||||
},
|
||||
"seeded": {
|
||||
"prefix": "Dziś warto rzucić okiem: {signal}",
|
||||
"signal": {
|
||||
"readiness": "gotowość",
|
||||
"recovery": "regeneracja"
|
||||
},
|
||||
"question": {
|
||||
"readiness": "Moja gotowość jest dziś nietypowa — z czego to wynika?",
|
||||
"recovery": "Moja regeneracja się zmieniła — co za tym stoi?"
|
||||
},
|
||||
"dismiss": "Odrzuć"
|
||||
"prefix": "Coach jest już przy {metric}"
|
||||
},
|
||||
"settings": "Ustawienia",
|
||||
"history": {
|
||||
@@ -8371,9 +8358,6 @@
|
||||
"enabledToast": "Proaktywne podpowiedzi Coacha włączone.",
|
||||
"disabledToast": "Proaktywne podpowiedzi Coacha wyłączone.",
|
||||
"saveError": "Nie udało się zapisać. Spróbuj ponownie później.",
|
||||
"suggestionsLabel": "Przykładowe sugestie",
|
||||
"suggestionsDesc": "Czasami pokazuje gotowe przykładowe pytanie na ekranie Coacha.",
|
||||
"suggestionsAria": "Przełącz przykładowe sugestie Coacha",
|
||||
"aiComposedLabel": "Pozwól Coachowi sformułować przypomnienie",
|
||||
"aiComposedDesc": "Gdy dostawca jest dostępny, proaktywne przypomnienie tworzy model zamiast stałego szablonu. Domyślnie wyłączone.",
|
||||
"aiComposedAria": "Przełącz proaktywne przypomnienie tworzone przez SI"
|
||||
|
||||
@@ -137,11 +137,6 @@ export const AI_ROUTES: Readonly<Record<string, AiRouteEntry>> = {
|
||||
capabilities: ["coach"],
|
||||
why: "The unread signal of a Coach that is unavailable is the quiet empty shape, never a refusal.",
|
||||
},
|
||||
"src/app/api/insights/coach/seeded-question/route.ts": {
|
||||
kind: "mixed",
|
||||
capabilities: ["coach"],
|
||||
why: "The opener exists only to open the Coach: `signal: null` while it is unavailable.",
|
||||
},
|
||||
"src/app/api/coach/about-me/route.ts": {
|
||||
kind: "mixed",
|
||||
capabilities: ["aboutMeQuestions"],
|
||||
|
||||
@@ -104,7 +104,6 @@ describe("GET /api/auth/me/notification-prefs", () => {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
@@ -141,7 +140,6 @@ describe("GET /api/auth/me/notification-prefs", () => {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
@@ -180,7 +178,6 @@ describe("GET /api/auth/me/notification-prefs", () => {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
@@ -230,7 +227,6 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
@@ -259,7 +255,6 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
@@ -289,7 +284,6 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
@@ -311,7 +305,6 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
@@ -395,7 +388,6 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
@@ -435,7 +427,6 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
@@ -464,7 +455,6 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
@@ -510,7 +500,6 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
/**
|
||||
* v1.21.2 (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.
|
||||
*/
|
||||
const findUniqueMock = vi.fn().mockResolvedValue({ notificationPrefs: null });
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: (...args: unknown[]) => findUniqueMock(...args) },
|
||||
},
|
||||
}));
|
||||
|
||||
// The `coach` capability decides whether an opener is served at all; pinned
|
||||
// here so the route test does not depend on the capability loader's reads.
|
||||
const AVAILABLE = { available: true, reason: null, onDeviceAllowed: true };
|
||||
const getAiCapability = vi.fn();
|
||||
vi.mock("@/lib/ai/capabilities/gate", () => ({
|
||||
getAiCapability: (...args: unknown[]) => getAiCapability(...args),
|
||||
}));
|
||||
|
||||
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);
|
||||
findUniqueMock.mockResolvedValue({ notificationPrefs: null });
|
||||
getAiCapability.mockResolvedValue(AVAILABLE);
|
||||
});
|
||||
|
||||
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",
|
||||
});
|
||||
expect(getAiCapability).toHaveBeenCalledWith("coach");
|
||||
expect((body.data as { ai?: unknown }).ai).toEqual(AVAILABLE);
|
||||
});
|
||||
|
||||
it.each(["operator_disabled", "user_disabled", "no_provider"] as const)(
|
||||
"answers { signal: null, ai } for %s without running the detector",
|
||||
async (reason) => {
|
||||
const ai = { available: false, reason, onDeviceAllowed: false };
|
||||
getAiCapability.mockResolvedValue(ai);
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { signal: unknown; ai: unknown };
|
||||
};
|
||||
expect(body.data).toEqual({ signal: null, ai });
|
||||
expect(detectDerivedBriefingSignals).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
it("returns { signal: null } and skips the detector when ambient suggestions are off", async () => {
|
||||
findUniqueMock.mockResolvedValue({
|
||||
notificationPrefs: { coach: { ambientSuggestions: false } },
|
||||
});
|
||||
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();
|
||||
// The opt-out short-circuits before the compute tier.
|
||||
expect(detectDerivedBriefingSignals).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,113 +0,0 @@
|
||||
/**
|
||||
* v1.21.2 (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). It IS registered in
|
||||
* the OpenAPI contract. This comment said the opposite until the route was
|
||||
* published — it shipped as a same-origin web surface and the sentence was
|
||||
* never revisited. A comment that contradicts the registry is worse than no
|
||||
* comment, because the next reader believes it instead of checking.
|
||||
*
|
||||
* Mirrors the sibling derived routes: `apiHandler` wrapper, `requireAuth`
|
||||
* (userId narrowed from the session/Bearer, never a body/query field),
|
||||
* analytics-read rate limit.
|
||||
*
|
||||
* A mixed read: the opener only exists to open the Coach, so it is served
|
||||
* only while the `coach` capability is available. Otherwise the route answers
|
||||
* 200 `{ signal: null, ai }`, the same neutral shape as "nothing notable",
|
||||
* with the reason beside it.
|
||||
*/
|
||||
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 { getAiCapability } from "@/lib/ai/capabilities/gate";
|
||||
import type { AiCapabilityState } from "@/lib/ai/capabilities/types";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { loadBaselineProfile } from "@/lib/insights/derived";
|
||||
import { detectDerivedBriefingSignals } from "@/lib/insights/derived-briefing";
|
||||
import { resolveCoachAmbientSuggestionsEnabled } from "@/lib/validations/notification-prefs";
|
||||
|
||||
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;
|
||||
/** Whether the Coach this opener feeds is available, and why not. */
|
||||
ai: AiCapabilityState;
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
// The opener feeds the Coach hero: with the Coach unavailable there is no
|
||||
// hero to seed, so the neutral shape answers and says why.
|
||||
const ai = await getAiCapability("coach");
|
||||
if (!ai.available) {
|
||||
return apiSuccess({ signal: null, ai } satisfies CoachSeededQuestionDTO);
|
||||
}
|
||||
|
||||
const rl = await checkAnalyticsReadRateLimit(user.id);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many analytics requests. Please retry later.", 429);
|
||||
}
|
||||
|
||||
// Server-authoritative ambient-suggestions opt-out: when the user turns
|
||||
// proactive suggestions off the hero must not seed an opener, so the route
|
||||
// skips the detector work and returns the neutral `{ signal: null }` shape.
|
||||
const prefsRow = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { notificationPrefs: true },
|
||||
});
|
||||
if (
|
||||
!resolveCoachAmbientSuggestionsEnabled(prefsRow?.notificationPrefs ?? null)
|
||||
) {
|
||||
annotate({
|
||||
action: { name: "coach.seeded-question.resolve" },
|
||||
meta: { has_signal: false, source_metric: "none", band: "none" },
|
||||
});
|
||||
return apiSuccess({ signal: null, ai } satisfies CoachSeededQuestionDTO);
|
||||
}
|
||||
|
||||
// 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, ai } satisfies CoachSeededQuestionDTO);
|
||||
});
|
||||
@@ -31,12 +31,17 @@ describe("<CoachHero>", () => {
|
||||
expect(html).toContain("Frage mich etwas zu deinen Daten");
|
||||
});
|
||||
|
||||
it("does not render starter-question suggestion chips", () => {
|
||||
// v1.18.10 (W4) — the two starter chips below the composer were
|
||||
// removed; the hero is greeting + composer only.
|
||||
const html = render(<CoachHero composer={null} />);
|
||||
it("leaves nothing under the composer", () => {
|
||||
// No starter chips, no seeded opener, no scope pill: the empty
|
||||
// conversation is the greeting and the field.
|
||||
const html = render(
|
||||
<CoachHero composer={<div data-slot="test-composer">composer</div>} />,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="coach-hero-chips"');
|
||||
expect(html).not.toContain('data-slot="coach-hero-chip"');
|
||||
expect(html).not.toContain('data-slot="coach-hero-scope-hint"');
|
||||
expect(html).not.toContain("coach-scope-hint");
|
||||
const afterComposer = html.split('data-slot="test-composer"')[1] ?? "";
|
||||
expect(afterComposer.replace(/<\/div>|composer|>/g, "")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
import { BOTH_PANELS_QUERY, listYields } from "../conversations-panel";
|
||||
import { NewChatFab } from "../new-chat-fab";
|
||||
|
||||
/**
|
||||
* The Coach page holds two docked panels beside the conversation: the
|
||||
* conversations and, right of them, the day. Below 1600 px only one is open.
|
||||
* The list folds when a day opens or the window narrows with both open; it
|
||||
* never folds again right after the person opened it beside a day (that
|
||||
* folds the day instead). The click paths and focus run in
|
||||
* `e2e/coach-layout.spec.ts`.
|
||||
*/
|
||||
const base = {
|
||||
docked: true,
|
||||
fitsBoth: false,
|
||||
listOpen: true,
|
||||
dayOpen: true,
|
||||
dayOpened: false,
|
||||
narrowed: false,
|
||||
};
|
||||
|
||||
describe("listYields", () => {
|
||||
it("folds the list when a day opens below 1600 px", () => {
|
||||
expect(listYields({ ...base, dayOpened: true })).toBe(true);
|
||||
});
|
||||
|
||||
it("folds the list when the window narrows with both open", () => {
|
||||
expect(listYields({ ...base, narrowed: true })).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps both open from 1600 px", () => {
|
||||
expect(listYields({ ...base, fitsBoth: true, dayOpened: true })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a list the person opened beside a day alone", () => {
|
||||
// No opening and no narrowing: the day is the one that gives way.
|
||||
expect(listYields(base)).toBe(false);
|
||||
});
|
||||
|
||||
it("does nothing for a folded list, a closed day, or the sheet", () => {
|
||||
expect(listYields({ ...base, dayOpened: true, listOpen: false })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(listYields({ ...base, narrowed: true, dayOpen: false })).toBe(false);
|
||||
expect(listYields({ ...base, dayOpened: true, docked: false })).toBe(false);
|
||||
});
|
||||
|
||||
it("splits at 1600 px", () => {
|
||||
expect(BOTH_PANELS_QUERY).toBe("(min-width: 1600px)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the folded list", () => {
|
||||
const source = readFileSync(
|
||||
join(
|
||||
process.cwd(),
|
||||
"src/components/insights/coach-panel/conversations-panel.tsx",
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
it("keeps an edge with its button, and sits left of the day", () => {
|
||||
expect(source).toContain('data-slot="coach-panel-rail"');
|
||||
expect(source).toContain('data-slot="coach-panel-expand"');
|
||||
expect(source).toContain('dockedOpen ? "w-72" : "w-12"');
|
||||
expect(source).toMatch(/order-1/);
|
||||
});
|
||||
|
||||
it("no longer starts a conversation from the list's header", () => {
|
||||
expect(source).not.toContain("coach-panel-new-chat");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<NewChatFab>", () => {
|
||||
it("is the Coach button's round face with a plus, named New conversation", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<NewChatFab onNewChat={() => undefined} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
const button = html.match(/<button[^>]*>/)?.[0] ?? "";
|
||||
expect(button).toContain('data-slot="coach-new-chat-fab"');
|
||||
expect(button).toContain('aria-label="New conversation"');
|
||||
expect(button).toContain("size-14");
|
||||
expect(button).toContain("rounded-full");
|
||||
expect(button).toContain("from-primary");
|
||||
// Above the composer by default, beside it where the column is wide.
|
||||
expect(button).toContain("bottom-full");
|
||||
expect(button).toContain("@min-[54rem]/composer:bottom-8");
|
||||
expect(html).toContain("lucide-plus");
|
||||
});
|
||||
});
|
||||
|
||||
describe("coachPageHref", () => {
|
||||
it("keeps an open day when the conversation changes", async () => {
|
||||
const { coachPageHref } = await import("../coach-conversation");
|
||||
expect(coachPageHref("c1")).toBe("/coach?c=c1");
|
||||
expect(coachPageHref(null)).toBe("/coach");
|
||||
expect(coachPageHref("c1", "?c=c0&day=2026-09-24")).toBe(
|
||||
"/coach?c=c1&day=2026-09-24",
|
||||
);
|
||||
expect(coachPageHref(null, "?day=2026-09-24&c=c0")).toBe(
|
||||
"/coach?day=2026-09-24",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,177 +0,0 @@
|
||||
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.21.2 (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);
|
||||
});
|
||||
|
||||
// v1.21.4 (C2) — the seeded opener carries a dismiss control so the user can
|
||||
// wave off today's suggestion. The control is gated to the seeded variant.
|
||||
it("renders the dismiss control for the seeded variant when onDismiss is given", () => {
|
||||
const html = render(
|
||||
<ScopeHintBadge
|
||||
variant="seeded"
|
||||
label="readiness"
|
||||
question="Why is my readiness lower today?"
|
||||
onSeed={() => {}}
|
||||
onDismiss={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="coach-scope-hint-dismiss"');
|
||||
});
|
||||
|
||||
it("omits the dismiss control for the seeded variant without onDismiss", () => {
|
||||
const html = render(
|
||||
<ScopeHintBadge
|
||||
variant="seeded"
|
||||
label="readiness"
|
||||
question="Why is my readiness lower today?"
|
||||
onSeed={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="coach-scope-hint-dismiss"');
|
||||
});
|
||||
|
||||
it("never renders the dismiss control for the non-dismissable scope pill", () => {
|
||||
const html = render(
|
||||
<ScopeHintBadge
|
||||
variant="scope"
|
||||
label="blood pressure"
|
||||
question="Walk me through my blood pressure trend."
|
||||
onSeed={() => {}}
|
||||
onDismiss={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="coach-scope-hint-dismiss"');
|
||||
});
|
||||
|
||||
it("sizes the seeded opener to its text and keeps it single-line", () => {
|
||||
const html = render(
|
||||
<ScopeHintBadge
|
||||
variant="seeded"
|
||||
label="readiness"
|
||||
question="Why is my readiness lower today?"
|
||||
onSeed={() => {}}
|
||||
onDismiss={() => {}}
|
||||
/>,
|
||||
);
|
||||
const seedTag = html.match(
|
||||
/<button[^>]*data-slot="coach-scope-hint-seed"[^>]*>/,
|
||||
);
|
||||
expect(seedTag).not.toBeNull();
|
||||
// v1.25.0 — intrinsic width, not stretched to the composer column: the
|
||||
// opener grows to its text and caps at the full width. The old narrow cap
|
||||
// and the full-width stretch are both gone.
|
||||
expect(html).not.toContain("max-w-md");
|
||||
// Intrinsic width capped at the column: `inline-flex` + `max-w-full`, not
|
||||
// the old `flex-1` / `w-full` full-width stretch.
|
||||
expect(seedTag?.[0]).toContain("inline-flex");
|
||||
expect(seedTag?.[0]).toContain("max-w-full");
|
||||
expect(seedTag?.[0]).not.toMatch(/\bflex-1\b/);
|
||||
// The question span truncates with an ellipsis rather than wrapping.
|
||||
expect(html).toContain("truncate");
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -7,18 +7,17 @@ import {
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { Paperclip, Plus, Sparkles } from "lucide-react";
|
||||
import { Paperclip, Sparkles } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { DAY_QUERY_PARAM } from "@/lib/day/contract";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { randomId } from "@/lib/random-id";
|
||||
import { apiDelete, apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
import type { CoachFollowUp, 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 type { InboundDocumentDetailDto } from "@/lib/validations/inbound-documents";
|
||||
import { MAX_COACH_ATTACHMENTS } from "@/lib/validations/inbound-documents";
|
||||
import { useModuleEnabled } from "@/hooks/use-module-enabled";
|
||||
@@ -30,7 +29,6 @@ import {
|
||||
|
||||
import { CoachDrawerBody } from "./coach-drawer-body";
|
||||
import { CoachHero } from "./coach-hero";
|
||||
import { ScopeHintBadge } from "./scope-hint-badge";
|
||||
import { CoachInput } from "./coach-input";
|
||||
import {
|
||||
GuidedQuestionBubble,
|
||||
@@ -53,12 +51,12 @@ import { CoachSettingsOverlay } from "./coach-settings-overlay";
|
||||
import { CoachTopBarTrail } from "./coach-top-bar-trail";
|
||||
import { ConversationsPanel } from "./conversations-panel";
|
||||
import { MobileRailTray } from "./mobile-rail-tray";
|
||||
import { NewChatFab } from "./new-chat-fab";
|
||||
import { SelfContextAdoptOffer } from "./self-context-adopt-offer";
|
||||
import { SourcesRail } from "./sources-rail";
|
||||
import { AttachmentPills, type AttachmentPillItem } from "./attachment-pills";
|
||||
import { AttachmentPicker } from "./attachment-picker";
|
||||
import { useResettableValue } from "./use-resettable-value";
|
||||
import { useCoachAmbientSuggestionsEnabled } from "@/hooks/use-coach-ambient-suggestions";
|
||||
import {
|
||||
useAttachCoachDocument,
|
||||
useCoachConversation,
|
||||
@@ -127,19 +125,6 @@ export function dropInvalidStagedAttachments(
|
||||
return pendingAttachmentIds.filter((_, i) => isErrorByIndex[i] === true);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.21.4 (C2) — the localStorage key that records the seeded "worth a look"
|
||||
* opener as dismissed for a given LOCAL calendar day. Date-stamped so the
|
||||
* dismissal resets at midnight: a new day mints a new key the flag has not
|
||||
* been written under yet, and the opener returns.
|
||||
*/
|
||||
function seededDismissStorageKey(now: Date = new Date()): string {
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(now.getDate()).padStart(2, "0");
|
||||
return `coach-seeded-dismissed:${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
export interface CoachConversationProps {
|
||||
/**
|
||||
* Pre-fill for the composer (suggested-prompt chip click). Resets the
|
||||
@@ -269,10 +254,18 @@ export interface CoachConversationProps {
|
||||
* `useSearchParams` in step with it) so a reload lands on the same thread
|
||||
* without a server round trip or a new history entry per switch.
|
||||
*/
|
||||
function coachPageHref(conversationId: string | null): string {
|
||||
return conversationId
|
||||
export function coachPageHref(
|
||||
conversationId: string | null,
|
||||
search = "",
|
||||
): string {
|
||||
const base = conversationId
|
||||
? `/coach?c=${encodeURIComponent(conversationId)}`
|
||||
: "/coach";
|
||||
// An open day stays open: the day docked beside the conversation is the
|
||||
// URL's `?day=`, and switching conversations must not close it.
|
||||
const day = new URLSearchParams(search).get(DAY_QUERY_PARAM);
|
||||
if (!day) return base;
|
||||
return `${base}${conversationId ? "&" : "?"}${DAY_QUERY_PARAM}=${encodeURIComponent(day)}`;
|
||||
}
|
||||
|
||||
export function CoachConversation({
|
||||
@@ -372,7 +365,7 @@ export function CoachConversation({
|
||||
// a shared tab) lands on it. The drawer has no URL of its own.
|
||||
function syncPageUrl(conversationId: string | null) {
|
||||
if (surface !== "page" || typeof window === "undefined") return;
|
||||
const href = coachPageHref(conversationId);
|
||||
const href = coachPageHref(conversationId, window.location.search);
|
||||
if (window.location.pathname + window.location.search === href) return;
|
||||
window.history.replaceState(window.history.state, "", href);
|
||||
}
|
||||
@@ -973,62 +966,16 @@ export function CoachConversation({
|
||||
pendingQuestions.length === 0 &&
|
||||
!pendingAdopt;
|
||||
|
||||
// v1.21.2 (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.
|
||||
// Something to leave: a stored conversation or a turn on its way.
|
||||
const threadStarted =
|
||||
currentConversationId !== null ||
|
||||
send.isStreaming ||
|
||||
!!send.optimisticUser ||
|
||||
!!send.streaming.content;
|
||||
|
||||
// The launch scope, for the drawer's sources rail: a conversation opened
|
||||
// from a metric surface names the metric there until its first turn.
|
||||
const a2Metric = launchScope?.metric ?? null;
|
||||
// v1.21.4 (C2) — once the user dismisses today's seeded opener it stays gone
|
||||
// for the rest of the local calendar day; the fetch is skipped too, so a
|
||||
// dismissed day costs nothing. SSR-safe lazy init guards `window`.
|
||||
const [seededDismissedToday, setSeededDismissedToday] = useState(() => {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
return window.localStorage.getItem(seededDismissStorageKey()) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
// v1.25.0 — the per-user opt-out for proactive ambient suggestions gates the
|
||||
// seeded opener (and skips its fetch when off). The A2 launch scope below is
|
||||
// NOT ambient — it reflects how the chat was opened — so it stays unaffected.
|
||||
const ambientSuggestionsEnabled = useCoachAmbientSuggestionsEnabled();
|
||||
const seededEnabled =
|
||||
heroActive &&
|
||||
a2Metric === null &&
|
||||
!seededDismissedToday &&
|
||||
ambientSuggestionsEnabled;
|
||||
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);
|
||||
}
|
||||
|
||||
function dismissSeeded() {
|
||||
setSeededDismissedToday(true);
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(seededDismissStorageKey(), "1");
|
||||
} catch {
|
||||
// Storage can throw (private mode / quota); the in-memory flag still
|
||||
// hides the opener for this session.
|
||||
}
|
||||
}
|
||||
|
||||
// `t()` returns the key string itself when a key is missing, so the only
|
||||
// reliable "is this key defined" signal is `t(key) !== key`.
|
||||
@@ -1037,10 +984,9 @@ export function CoachConversation({
|
||||
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.
|
||||
// The resolved metric label: prefers a per-source i18n key, falls back to
|
||||
// the brand-free English domain phrase so every source has a label. Null
|
||||
// when the launch carried no metric.
|
||||
const a2LabelKey = scopeSourceMetricLabelKey(a2Metric);
|
||||
const a2MetricLabel = a2Metric
|
||||
? (tOrNull(`insights.coach.scope.metric.${a2Metric}`) ??
|
||||
@@ -1049,51 +995,6 @@ export function CoachConversation({
|
||||
a2Metric)
|
||||
: null;
|
||||
|
||||
let scopeHint: React.ReactNode = null;
|
||||
if (fenced) {
|
||||
// v1.29.x (S7) — a fenced conversation has NO health snapshot to scope, so
|
||||
// the A2/A3 "the Coach is already on <metric>" openers are suppressed. The
|
||||
// honest fencing banner (below) is the only chrome the fenced hero shows.
|
||||
scopeHint = null;
|
||||
} else 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 &&
|
||||
!seededDismissedToday &&
|
||||
ambientSuggestionsEnabled
|
||||
) {
|
||||
// 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}
|
||||
onDismiss={dismissSeeded}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -1218,7 +1119,7 @@ export function CoachConversation({
|
||||
/>
|
||||
{!heroActive ? <h1 className="sr-only">{title}</h1> : null}
|
||||
{heroActive ? (
|
||||
<CoachHero composer={composerNode} scopeHint={scopeHint} />
|
||||
<CoachHero composer={composerNode} />
|
||||
) : (
|
||||
<>
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
@@ -1234,9 +1135,10 @@ export function CoachConversation({
|
||||
</div>
|
||||
<div
|
||||
data-slot="coach-page-composer"
|
||||
className="shrink-0 px-4 pt-2 pb-3 sm:px-6 sm:pb-4"
|
||||
className="@container/composer relative shrink-0 px-4 pt-2 pb-3 sm:px-6 sm:pb-4"
|
||||
>
|
||||
<div className="mx-auto w-full max-w-2xl">{composerStack}</div>
|
||||
<NewChatFab onNewChat={handleNewChat} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -1300,18 +1202,6 @@ export function CoachConversation({
|
||||
<p className="text-muted-foreground truncate text-xs">{tagline}</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleNewChat}
|
||||
data-slot="coach-new-chat"
|
||||
aria-label={t("insights.coach.newChat")}
|
||||
title={t("insights.coach.newChat")}
|
||||
className="text-muted-foreground hover:text-foreground size-11 shrink-0"
|
||||
>
|
||||
<Plus className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
{/* The same quick settings the page's panel opens: model and what
|
||||
the Coach may read. Everything else stays in Settings → AI. */}
|
||||
<CoachSettingsOverlay
|
||||
@@ -1342,6 +1232,11 @@ export function CoachConversation({
|
||||
/>
|
||||
}
|
||||
composer={composerStack}
|
||||
// New conversation floats in the conversation itself, once there is
|
||||
// one to leave.
|
||||
newChat={
|
||||
threadStarted ? <NewChatFab onNewChat={handleNewChat} /> : null
|
||||
}
|
||||
onHistoryClick={onRequestFullView ?? (() => setHistoryTrayOpen(true))}
|
||||
onOpenSourcesTray={() => setSourcesTrayOpen(true)}
|
||||
/>
|
||||
|
||||
@@ -42,6 +42,8 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
export interface CoachDrawerBodyProps {
|
||||
thread: React.ReactNode;
|
||||
composer: React.ReactNode;
|
||||
/** The New conversation button, floating above the composer. */
|
||||
newChat?: React.ReactNode;
|
||||
/**
|
||||
* Inline conversation list (page surface only). When set, it renders
|
||||
* as a collapsible left column on lg+ and the history button collapses
|
||||
@@ -67,6 +69,7 @@ export interface CoachDrawerBodyProps {
|
||||
export function CoachDrawerBody({
|
||||
thread,
|
||||
composer,
|
||||
newChat,
|
||||
historyRail,
|
||||
historyOpen = false,
|
||||
onToggleHistory,
|
||||
@@ -234,9 +237,10 @@ export function CoachDrawerBody({
|
||||
{/* Composer pinned to the bottom. */}
|
||||
<div
|
||||
data-slot="coach-drawer-composer"
|
||||
className="border-border/70 flex flex-col gap-2 border-t p-3 pb-[calc(env(safe-area-inset-bottom,0px)+0.75rem)] sm:p-4 sm:pb-[calc(env(safe-area-inset-bottom,0px)+1rem)]"
|
||||
className="border-border/70 @container/composer relative flex flex-col gap-2 border-t p-3 pb-[calc(env(safe-area-inset-bottom,0px)+0.75rem)] sm:p-4 sm:pb-[calc(env(safe-area-inset-bottom,0px)+1rem)]"
|
||||
>
|
||||
{composer}
|
||||
{newChat}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -28,25 +28,15 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
* in via `motion-safe` `animate-in` with capped delays; reduced-motion
|
||||
* users get the layout instantly.
|
||||
*
|
||||
* v1.21.2 (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.
|
||||
* The space under the composer stays empty: no suggested prompts and no
|
||||
* openers, so an empty conversation is the greeting and the field.
|
||||
*/
|
||||
export interface CoachHeroProps {
|
||||
/** The live `<CoachInput>` composer, re-parented into the hero. */
|
||||
composer: React.ReactNode;
|
||||
/**
|
||||
* v1.21.2 — 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, scopeHint }: CoachHeroProps) {
|
||||
export function CoachHero({ composer }: CoachHeroProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
return (
|
||||
@@ -104,23 +94,6 @@ export function CoachHero({ composer, scopeHint }: CoachHeroProps) {
|
||||
>
|
||||
{composer}
|
||||
</div>
|
||||
|
||||
{/* v1.21.2 (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>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useSyncExternalStore } from "react";
|
||||
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||
import Link from "next/link";
|
||||
import { PanelRightClose, PanelRightOpen, Plus, Target, X } from "lucide-react";
|
||||
import { PanelRightClose, PanelRightOpen, Target, X } from "lucide-react";
|
||||
|
||||
import { useOpenDay, yieldDay } from "@/components/day/day-layer-controller";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Sheet,
|
||||
@@ -42,18 +43,26 @@ import { useDeleteCoachConversationWithUndo } from "./use-coach";
|
||||
* Docked, the panel is a column of the shell beside the content column
|
||||
* (top bar and page): it runs the full height of the window, and the top bar
|
||||
* ends at its left edge. The panel's header row is the top bar's band, so
|
||||
* the two bottom borders draw one line. Shut, it is gone entirely (no rail)
|
||||
* and the top bar runs to the window's edge. The toggle is the last item of
|
||||
* the top bar, so it sits against the open panel and at the window's edge
|
||||
* when the panel is shut. The panel header carries the title, a link to
|
||||
* Plans, the settings gear and, in the sheet, a close button. New chat
|
||||
* opens the header row, left of the title: a fixed control that never sits
|
||||
* over the list, so the last conversation needs no reserve below it.
|
||||
* the two bottom borders draw one line. It folds like the day beside it: the
|
||||
* header's first control folds it to a narrow edge, and the edge's button
|
||||
* (in the same place) opens it again. Folded, the edge names the list.
|
||||
* The header carries the title, a link to Plans, the settings gear and, in
|
||||
* the sheet, a close button. New conversation is not here: it floats in the
|
||||
* conversation itself (`NewChatFab`).
|
||||
*
|
||||
* Keyboard: Escape closes the docked panel when focus is inside it and
|
||||
* The day docks right of the panel on this page too (page, conversations,
|
||||
* day). Below 1600 px only one of the two is open: a day opening folds the
|
||||
* list to its edge without touching the remembered choice, and opening the
|
||||
* list folds the day to its edge (`yieldDay`). From 1600 px both stay open.
|
||||
*
|
||||
* Below 1280 px the panel is a sheet, opened from the toggle at the end of
|
||||
* the top bar.
|
||||
*
|
||||
* Keyboard: Escape folds the docked panel when focus is inside it and
|
||||
* nothing inside (a row menu, the rename field, the settings popover) has
|
||||
* already claimed the key, then focus returns to the toggle. In the sheet
|
||||
* Radix owns Escape, the focus trap and the return.
|
||||
* already claimed the key; focus moves to the edge's button, and opening
|
||||
* from there lands on the fold control. In the sheet Radix owns Escape, the
|
||||
* focus trap and the return.
|
||||
*/
|
||||
export const COACH_PANEL_ID = "coach-conversations-panel";
|
||||
|
||||
@@ -68,6 +77,50 @@ const PANEL_HEADER_ICON = "size-5 pointer-fine:size-4";
|
||||
|
||||
const noSubscription = () => () => {};
|
||||
|
||||
/** Wide enough for the conversations and a day beside the conversation. */
|
||||
export const BOTH_PANELS_QUERY = "(min-width: 1600px)";
|
||||
|
||||
function subscribeBothPanels(callback: () => void): () => void {
|
||||
if (typeof window === "undefined" || !window.matchMedia) return () => {};
|
||||
const mql = window.matchMedia(BOTH_PANELS_QUERY);
|
||||
mql.addEventListener("change", callback);
|
||||
return () => mql.removeEventListener("change", callback);
|
||||
}
|
||||
|
||||
function useBothPanelsFit(): boolean {
|
||||
return useSyncExternalStore(
|
||||
subscribeBothPanels,
|
||||
() =>
|
||||
typeof window !== "undefined" && !!window.matchMedia
|
||||
? window.matchMedia(BOTH_PANELS_QUERY).matches
|
||||
: false,
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the docked list gives way to an open day: it does when the day
|
||||
* opens, or the window narrows, while both would be open below 1600 px.
|
||||
* Only those two moments fold it, so opening the list again beside an open
|
||||
* day (which folds the day) is never undone.
|
||||
*/
|
||||
export function listYields(state: {
|
||||
docked: boolean;
|
||||
fitsBoth: boolean;
|
||||
listOpen: boolean;
|
||||
dayOpened: boolean;
|
||||
narrowed: boolean;
|
||||
dayOpen: boolean;
|
||||
}): boolean {
|
||||
return (
|
||||
state.docked &&
|
||||
!state.fitsBoth &&
|
||||
state.listOpen &&
|
||||
state.dayOpen &&
|
||||
(state.dayOpened || state.narrowed)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the phone sheet is open. A settings deep link opens it only once
|
||||
* the viewport is known: the server and hydration renders take the narrow
|
||||
@@ -181,22 +234,62 @@ export function ConversationsPanel({
|
||||
const [settingsOpen, setSettingsOpen] = useState(openSettingsOnData);
|
||||
const [settingsOnData, setSettingsOnData] = useState(openSettingsOnData);
|
||||
|
||||
const dockedOpen = rememberedOpen || revealed;
|
||||
const expanded = docked ? dockedOpen : sheetOpen;
|
||||
|
||||
function toggle() {
|
||||
if (docked) {
|
||||
setRevealed(false);
|
||||
setOpen(!dockedOpen);
|
||||
} else {
|
||||
setSheetOpen(!sheetOpen);
|
||||
// The day beside the list (docked on this page from 1280 px too).
|
||||
const openDay = useOpenDay();
|
||||
const fitsBoth = useBothPanelsFit();
|
||||
// Folded for the day, without rewriting the remembered choice.
|
||||
const [yielded, setYielded] = useState(false);
|
||||
const dockedOpen = (rememberedOpen || revealed) && !yielded;
|
||||
const [seen, setSeen] = useState<{ day: string | null; fitsBoth: boolean }>({
|
||||
day: null,
|
||||
fitsBoth,
|
||||
});
|
||||
if (seen.day !== openDay || seen.fitsBoth !== fitsBoth) {
|
||||
setSeen({ day: openDay, fitsBoth });
|
||||
if (
|
||||
listYields({
|
||||
docked,
|
||||
fitsBoth,
|
||||
listOpen: dockedOpen,
|
||||
dayOpen: openDay !== null,
|
||||
dayOpened: seen.day === null && openDay !== null,
|
||||
narrowed: seen.fitsBoth && !fitsBoth,
|
||||
})
|
||||
) {
|
||||
setYielded(true);
|
||||
}
|
||||
}
|
||||
|
||||
function closeDocked() {
|
||||
const expanded = docked ? dockedOpen : sheetOpen;
|
||||
const collapseRef = useRef<HTMLButtonElement>(null);
|
||||
const expandRef = useRef<HTMLButtonElement>(null);
|
||||
// Where focus goes once the docked panel has folded or opened.
|
||||
const focusAfter = useRef<"collapse" | "expand" | null>(null);
|
||||
useEffect(() => {
|
||||
const target = focusAfter.current;
|
||||
focusAfter.current = null;
|
||||
if (target === "collapse") collapseRef.current?.focus();
|
||||
if (target === "expand") expandRef.current?.focus();
|
||||
}, [dockedOpen]);
|
||||
|
||||
function toggleSheet() {
|
||||
setSheetOpen(!sheetOpen);
|
||||
}
|
||||
|
||||
function expandDocked() {
|
||||
focusAfter.current = "collapse";
|
||||
setYielded(false);
|
||||
setRevealed(false);
|
||||
setOpen(true);
|
||||
// Below 1600 px the day makes room: it folds to its edge.
|
||||
if (!fitsBoth && openDay !== null) yieldDay();
|
||||
}
|
||||
|
||||
function collapseDocked() {
|
||||
focusAfter.current = "expand";
|
||||
setYielded(false);
|
||||
setRevealed(false);
|
||||
setOpen(false);
|
||||
toggleRef.current?.focus();
|
||||
}
|
||||
|
||||
function afterPick() {
|
||||
@@ -224,32 +317,34 @@ export function ConversationsPanel({
|
||||
: cn("border-sidebar-border px-3", SHELL_HEADER_BAND),
|
||||
)}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
onNewChat();
|
||||
afterPick();
|
||||
}}
|
||||
data-slot="coach-panel-new-chat"
|
||||
aria-label={t("insights.coach.newChat")}
|
||||
// The panel's one action, so the primary wash marks it apart
|
||||
// from the muted utility buttons on the right.
|
||||
className={cn(
|
||||
PANEL_HEADER_BUTTON,
|
||||
"bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary",
|
||||
)}
|
||||
>
|
||||
<Plus className={PANEL_HEADER_ICON} aria-hidden="true" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{t("insights.coach.newChat")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{inSheet ? null : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
ref={collapseRef}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={collapseDocked}
|
||||
data-slot="coach-panel-collapse"
|
||||
aria-controls={COACH_PANEL_ID}
|
||||
aria-expanded={true}
|
||||
aria-label={t("insights.coach.frame.hidePanel")}
|
||||
className={PANEL_HEADER_BUTTON}
|
||||
>
|
||||
{/* The day's own fold control and glyph, mirrored so it
|
||||
points the way the panel goes. */}
|
||||
<PanelRightClose
|
||||
className={cn(PANEL_HEADER_ICON, "-scale-x-100")}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{t("insights.coach.frame.hidePanel")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{inSheet ? (
|
||||
<SheetTitle className="min-w-0 flex-1 truncate text-lg leading-tight font-semibold">
|
||||
{t("insights.coach.historyTitle")}
|
||||
@@ -321,14 +416,16 @@ export function ConversationsPanel({
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<TopBarActions>
|
||||
<PanelToggle
|
||||
ref={toggleRef}
|
||||
expanded={expanded}
|
||||
label={toggleLabel}
|
||||
onToggle={toggle}
|
||||
/>
|
||||
</TopBarActions>
|
||||
{docked ? null : (
|
||||
<TopBarActions>
|
||||
<PanelToggle
|
||||
ref={toggleRef}
|
||||
expanded={expanded}
|
||||
label={toggleLabel}
|
||||
onToggle={toggleSheet}
|
||||
/>
|
||||
</TopBarActions>
|
||||
)}
|
||||
|
||||
{docked ? (
|
||||
<ShellSidePanel>
|
||||
@@ -337,28 +434,87 @@ export function ConversationsPanel({
|
||||
aria-label={t("insights.coach.historyTitle")}
|
||||
data-slot="coach-conversations-panel"
|
||||
data-state={dockedOpen ? "open" : "closed"}
|
||||
inert={!dockedOpen}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Escape" || event.defaultPrevented) return;
|
||||
if (
|
||||
!dockedOpen ||
|
||||
event.key !== "Escape" ||
|
||||
event.defaultPrevented
|
||||
) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
closeDocked();
|
||||
collapseDocked();
|
||||
}}
|
||||
className={cn(
|
||||
// A column of the shell's content row (`ShellSidePanel`), so it
|
||||
// runs from the top of the window to the bottom beside the top
|
||||
// bar. The width animates (offcanvas: shut, nothing of it
|
||||
// shows); the inner column
|
||||
// keeps 18rem and is pinned to the panel's left edge, so it
|
||||
// slides out to the right instead of squeezing. The thread column
|
||||
// keeps its width; only its centring moves.
|
||||
"bg-sidebar text-sidebar-foreground relative h-full shrink-0 overflow-hidden",
|
||||
// bar, left of a docked day (`order-1`, the day is `order-2`).
|
||||
// The width animates between the open column and the folded
|
||||
// edge; the open column keeps 18rem and is pinned to the
|
||||
// panel's left edge, so it slides instead of squeezing.
|
||||
"bg-sidebar text-sidebar-foreground relative order-1 h-full shrink-0 overflow-hidden",
|
||||
"transition-[width] duration-200 ease-linear motion-reduce:transition-none",
|
||||
dockedOpen ? "w-72" : "w-0",
|
||||
dockedOpen ? "w-72" : "w-12",
|
||||
)}
|
||||
>
|
||||
<div className="border-sidebar-border absolute inset-y-0 left-0 flex w-72 flex-col border-l">
|
||||
{body(false)}
|
||||
</div>
|
||||
{dockedOpen ? (
|
||||
<div className="border-sidebar-border absolute inset-y-0 left-0 flex w-72 flex-col border-l">
|
||||
{body(false)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
data-slot="coach-panel-rail"
|
||||
className="border-sidebar-border absolute inset-y-0 left-0 flex w-12 flex-col border-l"
|
||||
>
|
||||
{/* The top bar's band, so the bottom borders draw one line;
|
||||
the button sits where the open panel keeps its fold
|
||||
control. */}
|
||||
<div
|
||||
className={cn(
|
||||
SHELL_HEADER_BAND,
|
||||
"border-sidebar-border flex shrink-0 items-center justify-center",
|
||||
)}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
ref={expandRef}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={expandDocked}
|
||||
data-slot="coach-panel-expand"
|
||||
aria-controls={COACH_PANEL_ID}
|
||||
aria-expanded={false}
|
||||
aria-label={t("insights.coach.frame.showPanel")}
|
||||
className={PANEL_HEADER_BUTTON}
|
||||
>
|
||||
<PanelRightOpen
|
||||
className={cn(PANEL_HEADER_ICON, "-scale-x-100")}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
{t("insights.coach.frame.showPanel")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{/* The rest of the edge takes a click too and names the
|
||||
list; the keyboard has the button above. */}
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
onClick={expandDocked}
|
||||
className="hover:bg-sidebar-accent text-muted-foreground flex min-h-0 flex-1 cursor-pointer flex-col items-center justify-start pt-4 text-xs transition-colors"
|
||||
>
|
||||
<span className="[writing-mode:vertical-rl]">
|
||||
{t("insights.coach.historyTitle")}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</ShellSidePanel>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* New conversation, as the round button in the conversation's own window:
|
||||
* the floating Coach button's size and look, with a plus.
|
||||
*
|
||||
* It sits in the composer's row, so it belongs to the conversation and never
|
||||
* to the list beside it, and it moves wherever that column goes (a docked
|
||||
* list or day narrows the column, the button stays inside it). Where the
|
||||
* column is wide enough, it takes the bottom right corner beside the
|
||||
* composer, where the Coach button sits on other pages; where the composer
|
||||
* fills the width (a phone, the drawer, a narrow column), it rises just
|
||||
* above the composer's top edge so it never covers the field.
|
||||
*
|
||||
* The parent is the composer's wrapper and must be `relative` and an
|
||||
* `@container/composer`.
|
||||
*/
|
||||
export function NewChatFab({
|
||||
onNewChat,
|
||||
className,
|
||||
}: {
|
||||
onNewChat: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const label = t("insights.coach.newConversation");
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
data-slot="coach-new-chat-fab"
|
||||
// Steps aside while the on-screen keyboard is open (globals.css).
|
||||
data-keyboard-hide=""
|
||||
onClick={onNewChat}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={cn(
|
||||
"absolute right-4 bottom-full z-10 mb-3 size-14 rounded-full shadow-lg sm:right-6",
|
||||
// Beside the capped composer, in the column's bottom right corner.
|
||||
"@min-[54rem]/composer:right-8 @min-[54rem]/composer:bottom-8 @min-[54rem]/composer:mb-0",
|
||||
// The Coach button's face: a dark glyph on the brand gradient.
|
||||
"from-primary to-brand-pink text-background bg-gradient-to-br",
|
||||
"hover:from-primary/90 hover:to-brand-pink/90",
|
||||
"focus-visible:ring-offset-background focus-visible:ring-offset-2",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Plus className="text-background size-6" aria-hidden="true" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Sparkles, Target, X } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* v1.21.2 (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;
|
||||
/**
|
||||
* Dismiss the opener. Only honoured for `variant="seeded"` — the seeded
|
||||
* opener is a soft suggestion the user can wave off for the day, while the
|
||||
* A2 scope pill reflects how the chat was launched and is not dismissable.
|
||||
*/
|
||||
onDismiss?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ScopeHintBadge({
|
||||
variant,
|
||||
label,
|
||||
question,
|
||||
onSeed,
|
||||
onDismiss,
|
||||
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-primary 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. v1.25.0 — the
|
||||
opener sizes to its TEXT (intrinsic width, centred) instead of being
|
||||
stretched to the full composer width, which left a large empty tail
|
||||
that read as broken. It is guaranteed single-line: the question
|
||||
truncates with an ellipsis on a narrow phone rather than wrapping or
|
||||
forcing a horizontal scroll. The seeded variant keeps the dismiss
|
||||
control at the trailing edge. */}
|
||||
<div className="flex w-full items-center justify-center gap-1.5">
|
||||
<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",
|
||||
"inline-flex max-w-full min-w-0 items-center gap-2 rounded-xl border px-3.5 py-2.5 text-left",
|
||||
"transition-colors",
|
||||
"focus-visible:ring-input-focus focus-visible:ring-2 focus-visible:outline-none",
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 truncate text-sm leading-snug">
|
||||
{question}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="text-muted-foreground group-hover:text-foreground shrink-0 text-xs whitespace-nowrap"
|
||||
>
|
||||
{t("insights.coach.scope.tap")}
|
||||
</span>
|
||||
</button>
|
||||
{variant === "seeded" && onDismiss ? (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="coach-scope-hint-dismiss"
|
||||
onClick={onDismiss}
|
||||
aria-label={t("insights.coach.seeded.dismiss")}
|
||||
title={t("insights.coach.seeded.dismiss")}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:bg-muted/60 hover:text-foreground shrink-0",
|
||||
"flex size-9 items-center justify-center rounded-xl transition-colors",
|
||||
"focus-visible:ring-input-focus focus-visible:ring-2 focus-visible:outline-none",
|
||||
)}
|
||||
>
|
||||
<X className="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -34,7 +34,6 @@ interface CoachPrefsShape {
|
||||
nudgeVitals: boolean;
|
||||
nudgeRoutine: boolean;
|
||||
nudgeFrequency: "weekly" | "biweekly";
|
||||
ambientSuggestions: boolean;
|
||||
nudgeAiComposed: boolean;
|
||||
}
|
||||
|
||||
@@ -51,7 +50,6 @@ const COACH_PREF_DEFAULTS: CoachPrefsShape = {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
};
|
||||
|
||||
@@ -306,28 +304,6 @@ export function CoachNudgeCard({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* v1.25.0 — proactive ambient SUGGESTIONS opt-out. Independent of the
|
||||
push-nudge master switch above (a user can keep nudges but silence
|
||||
the example prompts), so it sits outside the `enabled` gate and rides
|
||||
the same `coach` prefs blob. */}
|
||||
<div className="border-border/60 flex min-h-11 items-center justify-between gap-3 border-t pt-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm">
|
||||
{t("notifications.coachNudge.suggestionsLabel")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("notifications.coachNudge.suggestionsDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={resolved.ambientSuggestions}
|
||||
onCheckedChange={(next) =>
|
||||
patchCoach({ ambientSuggestions: next }, null)
|
||||
}
|
||||
disabled={!isAuthenticated || saving}
|
||||
aria-label={t("notifications.coachNudge.suggestionsAria")}
|
||||
/>
|
||||
</div>
|
||||
{msg && (
|
||||
<p
|
||||
role="status"
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
|
||||
/**
|
||||
* v1.25.0 — the single client-side gate for proactive ambient Coach
|
||||
* SUGGESTIONS (today: the daily seeded example opener on the Coach hero).
|
||||
*
|
||||
* Reads the per-user opt-out from the shared notification-prefs blob
|
||||
* (`coach.ambientSuggestions`). Default ON: an unauthenticated read, a
|
||||
* still-loading query, or a missing field all resolve to `true` so the
|
||||
* suggestions show out of the box and only hide once the user explicitly turns
|
||||
* them off in Settings. Shares the `authNotificationPrefs` query key with the
|
||||
* settings card, so toggling the switch there flips this read in lockstep.
|
||||
*/
|
||||
interface AmbientSuggestionsPrefsShape {
|
||||
coach?: { ambientSuggestions?: boolean };
|
||||
}
|
||||
|
||||
export function useCoachAmbientSuggestionsEnabled(): boolean {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { data } = useQuery({
|
||||
queryKey: queryKeys.authNotificationPrefs(),
|
||||
queryFn: async () =>
|
||||
apiGet<AmbientSuggestionsPrefsShape>("/api/auth/me/notification-prefs"),
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
return data?.coach?.ambientSuggestions ?? true;
|
||||
}
|
||||
@@ -60,7 +60,6 @@ import {
|
||||
insightsGeneratePostResponse,
|
||||
coachReadQuery,
|
||||
coachReadStripResponse,
|
||||
coachSeededQuestionResponse,
|
||||
narrativeQuery,
|
||||
narrativeResponse,
|
||||
glp1TimelineQuery,
|
||||
@@ -399,28 +398,6 @@ export const insightsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/coach/seeded-question": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
summary: "Today's suggested Coach opener",
|
||||
description:
|
||||
"Resolves today's single most notable derived wellness signal into a tappable opener for the Coach's blank-chat hero, using the same confidence-gated detector the daily briefing uses. The selection happens SERVER-side and the client renders it rather than recomputing. `signal: null` is the ordinary answer and means the hero keeps its neutral greeting — never a fabricated opener. Two different causes produce that null and are indistinguishable on the wire: nothing crossed the notability gate, or the account has turned proactive suggestions off, in which case the detector does not run at all. The opener exists only to open the Coach, so it is served only while the `coach` AI capability is available; otherwise the answer is the same neutral `signal: null` and `ai` says why. Never refused for an AI reason. Shared analytics-read budget. Cookie or Bearer auth; not delegable.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The opener, or the neutral null.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
coachSeededQuestionResponse,
|
||||
"CoachSeededQuestionEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/narrative": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
|
||||
@@ -2747,38 +2747,6 @@ export const coachReadStripResponse = z
|
||||
"The two server-authoritative lines a metric sub-page renders above its chart. Pure compute over the baseline and correlation engines — no provider call, no cache table — so web and native decode the same resolved DTO.",
|
||||
});
|
||||
|
||||
// ── Coach seeded opener (`/api/insights/coach/seeded-question`) ───────
|
||||
|
||||
export const coachSeededQuestionResponse = z
|
||||
.object({
|
||||
signal: z
|
||||
.object({
|
||||
sourceMetric: z
|
||||
.string()
|
||||
.describe(
|
||||
"Sentinel id the client keys its localised copy on (`readiness` / `recovery`).",
|
||||
),
|
||||
score: z.number().describe("The latest 0..100 score."),
|
||||
band: z
|
||||
.string()
|
||||
.describe(
|
||||
"`yellow` or `red`. Green never surfaces — a good day is not notable.",
|
||||
),
|
||||
})
|
||||
.nullable()
|
||||
.describe(
|
||||
"Null whenever nothing crossed the detector's confidence and notability gate, AND whenever the account has turned proactive suggestions off, AND whenever the `coach` capability is unavailable (then `ai` says why) — all mean the hero keeps its neutral greeting rather than showing a fabricated opener.",
|
||||
),
|
||||
ai: aiCapabilityState.describe(
|
||||
"The `coach` capability the opener exists for.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "CoachSeededQuestion",
|
||||
description:
|
||||
"Today's single most notable derived wellness signal, resolved server-side into a tappable opener for the Coach's blank-chat hero. The client renders the resolved DTO and never recomputes the selection.",
|
||||
});
|
||||
|
||||
// ── Period narrative (`/api/insights/narrative`) ──────────────────────
|
||||
|
||||
export const narrativeQuery = z
|
||||
|
||||
@@ -76,12 +76,6 @@ export const coachKeys = {
|
||||
messageId,
|
||||
"trail",
|
||||
] as const,
|
||||
/**
|
||||
* v1.21.2 (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,
|
||||
/**
|
||||
* v1.22 (B2/B6) — the user's Coach episodic reminders (`GET /api/coach/
|
||||
* reminders`). The optional `status` arg keys the in-app tile read
|
||||
|
||||
@@ -109,7 +109,6 @@ describe("parseNotificationPrefs", () => {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
@@ -134,7 +133,6 @@ describe("parseNotificationPrefs", () => {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
@@ -161,7 +159,6 @@ describe("parseNotificationPrefs", () => {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
@@ -204,7 +201,6 @@ describe("resolveNotificationPrefs (deep-merge)", () => {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
@@ -235,7 +231,6 @@ describe("resolveNotificationPrefs (deep-merge)", () => {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
@@ -263,7 +258,6 @@ describe("resolveNotificationPrefs (deep-merge)", () => {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
@@ -291,7 +285,6 @@ describe("resolveNotificationPrefs (deep-merge)", () => {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
@@ -320,7 +313,6 @@ describe("resolveNotificationPrefs (deep-merge)", () => {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
|
||||
@@ -125,14 +125,6 @@ const coachPrefsSchema = z
|
||||
nudgeRoutine: z.boolean(),
|
||||
/** v1.16.5 — frequency cap: one nudge per 7 or per 14 days. */
|
||||
nudgeFrequency: z.enum(["weekly", "biweekly"]),
|
||||
/**
|
||||
* v1.25.0 — opt-out for the proactive ambient SUGGESTIONS surfaced in the
|
||||
* UI (the daily seeded example opener on the Coach hero + the "try asking"
|
||||
* prompt chips). Default ON. Separate from `nudgesEnabled` (the push-nudge
|
||||
* master switch) so a user can keep the nudges but silence the example
|
||||
* prompts, or vice versa.
|
||||
*/
|
||||
ambientSuggestions: z.boolean(),
|
||||
/**
|
||||
* v1.25.0 — opt-IN for AI-composed nudge copy. Default OFF: the proactive
|
||||
* nudge ships a deterministic warm localized template, and that template is
|
||||
@@ -219,11 +211,6 @@ export interface NotificationPrefs {
|
||||
nudgeRoutine: boolean;
|
||||
/** v1.16.5 — cap interval: "weekly" (7 d) or "biweekly" (14 d). */
|
||||
nudgeFrequency: "weekly" | "biweekly";
|
||||
/**
|
||||
* v1.25.0 — proactive ambient example suggestions in the UI (seeded
|
||||
* opener + suggested-prompt chips). Default `true`.
|
||||
*/
|
||||
ambientSuggestions: boolean;
|
||||
/**
|
||||
* v1.25.0 — opt-in for AI-composed nudge copy. Default `false`; the
|
||||
* deterministic template stays the default + fail-closed fallback.
|
||||
@@ -285,7 +272,6 @@ export const DEFAULT_NOTIFICATION_PREFS: NotificationPrefs = {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
@@ -483,16 +469,6 @@ export interface CoachNudgePrefs {
|
||||
aiComposed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.25.0 — the single source every proactive ambient SUGGESTION surface
|
||||
* consults (the daily seeded example opener on the Coach hero + the
|
||||
* suggested-prompt chips). Default `true`; a null / drifted row resolves to
|
||||
* the documented default so the suggestions work out of the box.
|
||||
*/
|
||||
export function resolveCoachAmbientSuggestionsEnabled(raw: unknown): boolean {
|
||||
return parseNotificationPrefs(raw).coach.ambientSuggestions;
|
||||
}
|
||||
|
||||
export function resolveCoachNudgePrefs(raw: unknown): CoachNudgePrefs {
|
||||
const coach = parseNotificationPrefs(raw).coach;
|
||||
return {
|
||||
|
||||
Reference in new issue
Block a user