Merge pull request #1163 from MBombeck/release/v1.40.2

chore(release): v1.40.2
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-10-05 10:03:51 +02:00
commit 643e1b3c25
169 files changed
+13502 -2262

No files matched your search

+40
View File
@@ -1,5 +1,45 @@
# Changelog
## [1.40.2] — 2026-10-05
A calmer Coach with conversations in a side panel and settings in place; the
top of the dashboard says what matters today; documents filter by procedure.
### Added
- **Coach panel.** Conversations in a collapsible right panel (a sheet on a
phone), grouped by day, with rename, delete with undo and a new-chat
button; a gear opens quick settings (provider, model and reasoning where
they are the user's, and the lookback). `/coach?settings=data` opens it.
- **Coach lookback as a real limit.** 7, 30 or 90 days, 12 months
(`lastYear`, new) or all; it limits the snapshot and every Coach tool, a
read beyond it answers `outside_reach`. MCP keeps its own windows.
- **Today overview.** The digest gains `lead`, `today[]`, `restMode` and
`score.steadyWeeks`/`steadyAtLeast`: a lead line with content (deterministic
without AI), up to five facts (medications, an appointment today or
tomorrow, last night, vitals, cycle, Rest Mode), the ring with how long the
score has been steady. The morning push line carries no vital values.
- **Procedure filter in the document archive (#1154).** `linkedProcedures` on
the usage endpoint; the list already accepted `encounterId`.
### Changed
- **Coach answers.** One action row (copy, read aloud, retry, time; tokens and
model); web thumbs and the evidence block removed; remember is an icon;
follow-up chips in the answer column; a scroll-to-bottom button; the
composer's "+" menu removed.
- **Settings → Coach** summarises what the Coach sees and links into the
overlay.
- Coach preferences parse field by field, so one invalid value no longer
drops the others.
### Fixed
- The medications fact counts the whole day on a 25-hour DST day.
- Providers without credentials are no longer offered in the Coach overlay;
an operator-managed provider shows as such.
- Document filter controls match the search field's height on a phone.
## [1.40.1] — 2026-10-04
Restore opens older encrypted values again; faster pages on a long-used
+2 -2
View File
@@ -1,5 +1,5 @@
{
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.27 found /insights sitting a few hundred bytes under 445 on main and a few hundred over it on the release branch — the page had been at its ceiling for three releases and the release only had to breathe on it. The weight it was carrying was not its own: the authenticated shell imported one destination predicate from the `@/lib/record-settings` barrel, and the barrel also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode into the eager bundle of every authenticated route for a function that reads a URL slug. The shell imports the `classification` leaf now. Measured across all 585 client-rendered routes: none grew by a byte, 100 fell, by 4.7 KB at the median and 8.1 KB at the most; /insights 444 to 437, /measurements 442 to 435, / 456 to 450, /insights/mood 455 to 450. The whole-build sum moved the other way, 3451 to 3476, and that direction is the metric rather than the change: the aggregate counts each emitted chunk once, so a graph that leaves the chunk group every route shares gets counted again wherever it lands — here inside the deferred settings chunks that actually use it. Bytes on disk rose 25 KB; bytes a reader downloads only ever fell. The aggregate cap moves from 3452 to 3500, the same 24 KB headroom as the four re-measurements before it. Per-route ceilings keep their old values rather than tightening to the new floor: the CI runner measures each route about 1.5 KB heavier than a local arm64 build does, and a ceiling set from a local number would fail on a build that had not regressed. The static-catalog guard and the one-Recharts-chunk guard remain unchanged. Raised to 3700 at v1.37.32 for the seventh interface language (Korean): the lazy message catalogs went 6->7 chunks (1026 KB gz measured), pushing the aggregate to 3631 against the old 3500. The raise is entirely the new catalog; no route budget moved, and the four eager route ceilings are unchanged and still passing. Most of the aggregate is the dynamic locale-switch and fallback path that a first paint never loads (see issue #253), so this ceiling guards bytes almost no visitor requests; 3700 leaves ~69 KB for ordinary drift until that double-count is cleaned up. v1.38.14 re-measures the four eager routes and changes what the number means. The ceilings had been re-stated but never re-based since v1.37.4, so /inLine truncated
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.27 found /insights sitting a few hundred bytes under 445 on main and a few hundred over it on the release branch — the page had been at its ceiling for three releases and the release only had to breathe on it. The weight it was carrying was not its own: the authenticated shell imported one destination predicate from the `@/lib/record-settings` barrel, and the barrel also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode into the eager bundle of every authenticated route for a function that reads a URL slug. The shell imports the `classification` leaf now. Measured across all 585 client-rendered routes: none grew by a byte, 100 fell, by 4.7 KB at the median and 8.1 KB at the most; /insights 444 to 437, /measurements 442 to 435, / 456 to 450, /insights/mood 455 to 450. The whole-build sum moved the other way, 3451 to 3476, and that direction is the metric rather than the change: the aggregate counts each emitted chunk once, so a graph that leaves the chunk group every route shares gets counted again wherever it lands — here inside the deferred settings chunks that actually use it. Bytes on disk rose 25 KB; bytes a reader downloads only ever fell. The aggregate cap moves from 3452 to 3500, the same 24 KB headroom as the four re-measurements before it. Per-route ceilings keep their old values rather than tightening to the new floor: the CI runner measures each route about 1.5 KB heavier than a local arm64 build does, and a ceiling set from a local number would fail on a build that had not regressed. The static-catalog guard and the one-Recharts-chunk guard remain unchanged. Raised to 3700 at v1.37.32 for the seventh interface language (Korean): the lazy message catalogs went 6->7 chunks (1026 KB gz measured), pushing the aggregate to 3631 against the old 3500. The raise is entirely the new catalog; no route budget moved, and the four eager route ceilings are unchanged and still passing. Most of the aggregate is the dynamic locale-switch and fallback path that a first paint never loads (see issue #253), so this ceiling guards bytes almost no visitor requests; 3700 leaves ~69 KB for ordinary drift until that double-count is cleaned up. v1.38.14 re-measures the four eager routes and changes what the number means. The ceilings had been re-stated but never re-based since v1.37.4, so /inLine truncated
"routeBaselineKbGz": {
"/page": 429,
"/insights/page": 368,
@@ -7,6 +7,6 @@
"/insights/mood/page": 379
},
"routeDriftAllowanceKbGz": 20,
"totalClientKbGz": 4100,
"totalClientKbGz": 4155,
"maxRechartsChunks": 1
}
+162 -7
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.40.1
version: 1.40.2
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -16410,7 +16410,8 @@ paths:
required: false
schema:
type: string
description: Only documents linked to this visit.
description: Only documents linked to this visit. The procedure filter sends a `linkedProcedures` id from the usage
read; an id that is not one of the caller's visits matches nothing.
- name: year
in: query
required: false
@@ -16744,8 +16745,11 @@ paths:
summary: Storage usage + effective limits
description: "The caller's vault usage and effective limits: `usedBytes` (every non-purged row — tombstones inside the
30-day undo grace still count), `quotaBytes` (per-user override ?? instance default), `maxFileBytes`
(admin-tunable per-file cap), `acceptedExtensions` for picker `accept` lists, and `linkedEpisodes` (episodes
carrying at least one live document link — the condition-filter chips). Read this before offering an upload."
(admin-tunable per-file cap), `acceptedExtensions` for picker `accept` lists, `linkedEpisodes` (episodes
carrying at least one live document link — the condition-filter chips), and `linkedProcedures` (visits of kind
PROCEDURE carrying at least one live document link, newest first — the procedure-filter choices; pass one's
`encounterId` to the list's `encounterId` filter). `linkedProcedures` is empty when a sharing grant does not
cover the visits section. Read this before offering an upload."
responses:
"200":
description: Usage + limits.
@@ -25929,7 +25933,10 @@ components:
- last7days
- last30days
- last90days
- lastYear
- allTime
description: "How far back the Coach may read, in every answer: snapshot, retrieval tools and summaries alike. `allTime`
(the default) is no limit. Formerly only the starting window; `lastYear` came with the limit."
dataClusters:
maxItems: 10
type: array
@@ -32076,7 +32083,10 @@ components:
- last7days
- last30days
- last90days
- lastYear
- allTime
description: "How far back the Coach may read, in every answer: snapshot, retrieval tools and summaries alike. `allTime`
(the default) is no limit. Formerly only the starting window; `lastYear` came with the limit."
dataClusters:
maxItems: 10
type: array
@@ -48822,7 +48832,10 @@ components:
- last7days
- last30days
- last90days
- lastYear
- allTime
description: "How far back the Coach may read, in every answer: snapshot, retrieval tools and summaries alike. `allTime`
(the default) is no limit. Formerly only the starting window; `lastYear` came with the limit."
dataClusters:
maxItems: 10
type: array
@@ -68514,6 +68527,45 @@ components:
- episodeId
- name
additionalProperties: false
linkedProcedures:
type: array
items:
type: object
properties:
encounterId:
type: string
occurredAt:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
reason:
anyOf:
- type: string
- type: "null"
bodySite:
anyOf:
- type: string
- type: "null"
laterality:
anyOf:
- type: string
enum:
- LEFT
- RIGHT
- BOTH
- type: "null"
practitionerName:
anyOf:
- type: string
- type: "null"
required:
- encounterId
- occurredAt
- reason
- bodySite
- laterality
- practitionerName
additionalProperties: false
assistAvailable:
type: boolean
description: "Whether a document read can run for this record now: a provider can serve it and the `documentAi`
@@ -68539,6 +68591,7 @@ components:
- maxFileBytes
- acceptedExtensions
- linkedEpisodes
- linkedProcedures
- assistAvailable
- contentIndex
additionalProperties: false
@@ -72427,6 +72480,20 @@ components:
so this is what distinguishes a narrow score from a broad one. Optional so older cached digests
without the field stay valid.
$ref: "#/components/schemas/HealthScoreBasis"
steadyWeeks:
description: Whole weeks the score has held where it is (same band, within two points, computed the same way), read off
the stored daily scores. Null when it has not held for two weeks, when the stored record does not
reach today, or when the score on screen differs from the newest stored day in value, band,
algorithm version or composition. Shown in place of a delta, never beside one.
anyOf:
- type: integer
exclusiveMinimum: 0
maximum: 9007199254740991
- type: "null"
steadyAtLeast:
description: "True when the run reaches back past everything the read covered (120 days): `steadyWeeks` is then a lower
bound, said as \"at least\", and the run's start is unknown. Absent on older cached digests."
type: boolean
required:
- value
- band
@@ -72469,11 +72536,60 @@ components:
anyOf:
- type: string
- type: "null"
description: First sentence of the cached briefing paragraph. Null while the `briefing` capability is unavailable;
`line` then falls to its deterministic floor.
description: "The first sentence of the cached briefing paragraph that says something: a greeting or a sentence that
only repeats the score is skipped. Null while the `briefing` capability is unavailable or when no sentence
qualifies; `line` then falls to its deterministic floor."
lead:
anyOf:
- type: object
properties:
text:
type: string
source:
type: string
enum:
- reaction
- briefing
- signal
description: "`reaction` and `briefing` are cached model text; `signal` is the deterministic sentence about the day's
strongest signal."
required:
- text
- source
additionalProperties: false
- type: "null"
description: "The Today hero's lead line, resolved: the reaction line or a briefing sentence while their capabilities
are available, otherwise a deterministic sentence about the day's strongest signal (a vital outside its
personal range, an unusual night, vitals in range, last night). Null when there is nothing to say. When the
deterministic sentence is built from a fact, that fact is left out of `today`. Additive since v1.40."
today:
maxItems: 5
type: array
items:
$ref: "#/components/schemas/DailyTodayFact"
description: "The Today overview: up to 5 statements about the day in priority order (Rest Mode, medications taken
today, an appointment today or tomorrow that is not already on the rail, last night's sleep, vitals against
their personal range, cycle day), never padded. A fact about a switched-off module is absent. A narrow
surface shows the first 4. Additive since v1.40."
restMode:
anyOf:
- type: object
properties:
day:
type: integer
exclusiveMinimum: 0
maximum: 9007199254740991
description: 1-based day of the episode on the reader's calendar.
required:
- day
additionalProperties: false
- type: "null"
description: "Rest Mode: present while an illness episode is active and the illness module is on, else null. Additive
since v1.40."
line:
type: string
description: "Push / lock-screen line: cached-AI lead with a deterministic floor."
description: "Push / lock-screen line: cached-AI lead with a deterministic floor. Carries no number (no reading, dose or
score), since it is shown on a locked phone; the in-app `lead` keeps its figures."
worthALook:
type: array
items:
@@ -72535,6 +72651,9 @@ components:
- score
- topSignal
- briefingLead
- lead
- today
- restMode
- line
- worthALook
- justIn
@@ -72543,6 +72662,39 @@ components:
additionalProperties: false
description: The one data spine of the daily-value system — composed from already-cached data, never a fresh AI call.
Consumed by the Today surface, the daily push, and a future iOS widget.
DailyTodayFact:
type: object
properties:
kind:
type: string
enum:
- rest_mode
- medications
- appointment
- sleep
- vitals
- cycle
description: Closed fact kind.
label:
type: string
description: Localised label (resolved server-side).
value:
type: string
description: Localised value (resolved server-side).
href:
type: string
description: The page the fact belongs to.
moduleKey:
description: The module that admitted the fact; absent for core data.
type: string
required:
- kind
- label
- value
- href
additionalProperties: false
description: One statement about the day on the Today overview. A fact never asks the reader to do anything; the rail is
the only place that does.
DailyPriorityItem:
type: object
properties:
@@ -77933,7 +78085,10 @@ components:
- last7days
- last30days
- last90days
- lastYear
- allTime
description: "How far back the Coach may read, in every answer: snapshot, retrieval tools and summaries alike. `allTime`
(the default) is no limit. Formerly only the starting window; `lastYear` came with the limit."
dataClusters:
maxItems: 10
type: array
+6
View File
@@ -78,6 +78,12 @@ roll back:
dev --create-only --name vX_Y_Z_rollback_<col>`), edit the SQL,
commit, and re-deploy. Never `migrate resolve --rolled-back` against
production unless you've taken a fresh `pg_dump` first.
4. Rolling back from v1.40.2 or later to v1.40.1 or earlier: an account
that saved the Coach lookback "Last year" reads its whole Coach
preference blob as defaults on the older version, metric exclusions
included, until the setting is saved again. Ask affected accounts to
pick another lookback before the rollback, or re-save their exclusions
after it.
## Catalog templates
+9 -8
View File
@@ -1047,23 +1047,24 @@ test.describe("axe-core authenticated route and state matrix", () => {
);
});
await test.step(`${theme} /coach open evidence disclosure and source chips`, async () => {
await test.step(`${theme} /coach open the areas an older answer looked at`, async () => {
await page.goto(`/coach?c=${A11Y_CONVERSATION_ID}`, {
waitUntil: "domcontentloaded",
});
const evidence = page.locator('[data-slot="coach-evidence"]').first();
await expect(evidence).toBeVisible({ timeout: 15_000 });
await evidence.locator("summary").click();
await expect(evidence).toHaveAttribute("open", "");
const steps = page.locator('[data-slot="coach-turn-steps"]').first();
await expect(steps).toBeVisible({ timeout: 15_000 });
const toggle = steps.locator('[data-slot="coach-turn-steps-toggle"]');
await toggle.click();
await expect(toggle).toHaveAttribute("aria-expanded", "true");
await expect(
evidence.locator('[data-slot="coach-source-chips"]'),
steps.locator('[data-slot="coach-turn-areas"]'),
).toBeVisible();
blocking.push(
...(await scanPaintedState(
page,
theme,
"/coach open evidence disclosure and source chips",
evidence,
"/coach open the areas an older answer looked at",
steps,
)),
);
});
+336
View File
@@ -0,0 +1,336 @@
import { mkdirSync } from "node:fs";
import { join } from "node:path";
import type { Locator, Page, 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";
/**
* The geometry of a Coach answer, at 1440 x 900 (desktop project) and
* 390 x 844 (mobile project).
*
* Contracts under test:
*
* 1. Under an answer there is exactly one action row: the icon group and
* the time on the left, the tokens and model on the right, both halves
* on one line (their centres within 2 px, the row no taller than one
* 44 px target), the left group starting on the bubble's left edge and
* the meta ending inside the answer column.
* 2. The follow-up chips sit in the answer's column: their left edge is
* the bubble's, their text is the answer's size, and a chip is at most
* 36 px tall beside a pointer and at least 44 px on a phone.
* 3. Under a question, copy and remember share one row; nothing stacks
* below it.
* 4. Scrolled away from the end, a round button appears; it brings the
* thread back to the end and goes away again.
* 5. No thumbs, no evidence disclosure, no horizontal overflow.
*
* Screenshots are attached to the report (no pixel baselines) and, with
* `COACH_SHOT_DIR` set, written there too.
*/
const CONVERSATION_ID = "coach-answer-layout-e2e";
const TITLE = "Blood pressure and sleep";
const ANSWERS = [
"Your blood pressure stayed in a steady band over the last month. The morning readings sat a little higher than the evening ones, which is common, and none of the weeks stands out from the others.",
"Sleep looked regular on most nights. The weekends ran about forty minutes later at both ends, so the total stayed much the same while the timing moved.",
"Your resting heart rate held steady across the same weeks. Nothing in it lines up with the later weekend nights, so the two seem to move independently for now.",
"The period before looked much the same for blood pressure. Two readings in the second week were higher than the rest, and both were taken shortly after a workout.",
"Taken together, the month reads calm: steady blood pressure, regular sleep and a resting heart rate without surprises. Measuring at the same time each morning keeps the trend easy to read.",
];
const QUESTIONS = [
"How was my blood pressure this month?",
"And my sleep?",
"What about my resting heart rate?",
"Compare blood pressure with the period before",
"Sum it up for me",
];
const STEPS = [
{
id: "s1",
tool: "get_metric_series",
labelKey: "coach.step.readWindow",
label: "Checking: Blood pressure, last 30 days",
domain: "bp",
window: "last30days",
status: "done",
count: 28,
},
{
id: "s2",
tool: "get_sleep",
labelKey: "coach.step.readWindow",
label: "Checking: Sleep, last 30 days",
domain: "sleep",
window: "last30days",
status: "done",
count: 30,
},
];
const FOLLOW_UPS = [
{
id: "f1",
kind: "previous_period",
labelKey: "coach.followUp.previousPeriod",
label: "Compare with the period before",
reuse: false,
origin: "server",
},
{
id: "f2",
kind: "related_metric",
labelKey: "coach.followUp.relatedMetric",
label: "See also: Resting HR",
reuse: false,
origin: "server",
},
];
function messages() {
// Today, a few minutes apart, so the time reads as a bare clock.
const base = Date.now() - 60 * 60 * 1000;
const at = (minute: number) =>
new Date(base + minute * 60 * 1000).toISOString();
return ANSWERS.flatMap((answer, index) => [
{
id: `${CONVERSATION_ID}-u${index + 1}`,
role: "user",
content: QUESTIONS[index],
createdAt: at(index * 2),
metricSource: null,
providerType: null,
promptVersion: null,
tokensUsed: null,
model: null,
},
{
id: `${CONVERSATION_ID}-a${index + 1}`,
role: "assistant",
content: answer,
createdAt: at(index * 2 + 1),
metricSource: {
windows: ["last30days"],
metrics: ["bp", "sleep"],
steps: STEPS,
...(index === ANSWERS.length - 1 ? { followUps: FOLLOW_UPS } : {}),
},
providerType: "mock",
promptVersion: "e2e",
tokensUsed: 1234,
model: "gpt-4o-mini-2024-07-18",
},
]);
}
function fulfilJson(route: Route, data: unknown) {
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data, error: null }),
});
}
async function mockConversation(page: Page) {
const summary = {
id: CONVERSATION_ID,
title: TITLE,
createdAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
updatedAt: new Date().toISOString(),
messageCount: ANSWERS.length * 2,
fenced: false,
attachments: [],
documentTitle: null,
};
await page.route(
/\/api\/insights\/chat(?:\/[^?]+)?(?:\?.*)?$/,
async (route) => {
const url = new URL(route.request().url());
if (url.pathname.startsWith(`/api/insights/chat/${CONVERSATION_ID}/`)) {
return fulfilJson(route, { results: [] });
}
if (url.pathname === `/api/insights/chat/${CONVERSATION_ID}`) {
return fulfilJson(route, {
...summary,
attachmentCount: 0,
summary: null,
messages: messages(),
});
}
return fulfilJson(route, { conversations: [summary], nextCursor: null });
},
);
await page.route("**/api/insights/coach/nudge-status*", (route) =>
fulfilJson(route, { nudgedAt: null, unread: false }),
);
await page.route("**/api/coach/about-me/questions*", (route) =>
fulfilJson(route, { questions: [] }),
);
}
async function box(locator: Locator) {
const b = await locator.boundingBox();
expect(b, "element has a box").not.toBeNull();
return b!;
}
async function shoot(page: Page, testInfo: TestInfo, name: string) {
const body = await page.screenshot({ fullPage: false });
await testInfo.attach(name, { body, contentType: "image/png" });
const dir = process.env.COACH_SHOT_DIR;
if (dir) {
mkdirSync(dir, { recursive: true });
await page.screenshot({ path: join(dir, `${name}.png`) });
}
}
const VIEWPORTS = {
"chromium-desktop": { width: 1440, height: 900, label: "1440" },
"chromium-mobile": { width: 390, height: 844, label: "390" },
} as const;
test.describe("Coach answer layout", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("one action row, chips in the answer column, jump to the latest", async ({
page,
}, testInfo) => {
const viewport = VIEWPORTS[testInfo.project.name as keyof typeof VIEWPORTS];
test.skip(!viewport, "measured in the desktop and the mobile project");
const desktop = viewport.width >= 640;
await page.setViewportSize({
width: viewport.width,
height: viewport.height,
});
await page.emulateMedia({ reducedMotion: "reduce" });
await serveAiBlock(page, aiBlockAvailable());
await mockConversation(page);
await page.goto(`/coach?c=${CONVERSATION_ID}`, {
waitUntil: "domcontentloaded",
});
const thread = page.locator('[data-slot="coach-message-thread"]');
const answers = page.locator('[data-slot="coach-bubble-assistant"]');
await expect(answers).toHaveCount(ANSWERS.length, { timeout: 15_000 });
const last = answers.last();
await expect(last).toContainText(ANSWERS.at(-1)!.slice(0, 40));
// The thread opens at its end.
await expect
.poll(() =>
thread.evaluate(
(el) => el.scrollHeight - el.scrollTop - el.clientHeight,
),
)
.toBeLessThanOrEqual(2);
if (desktop) await last.hover();
await shoot(page, testInfo, `answer-${viewport.label}`);
// 5. Nothing of the old furniture.
await expect(
page.locator('[data-slot^="coach-message-feedback"]'),
).toHaveCount(0);
await expect(page.locator('[data-slot^="coach-evidence"]')).toHaveCount(0);
await expect(page.locator('[data-slot="coach-token-footer"]')).toHaveCount(
0,
);
for (const el of [page.locator("#main-content"), thread]) {
const overflow = await el.evaluate((n) => n.scrollWidth - n.clientWidth);
expect(overflow, "no horizontal overflow").toBeLessThanOrEqual(0);
}
// 1. One action row under the answer.
const bubble = last.locator('[data-slot="coach-answer-bubble"]');
const column = last.locator('[data-slot="coach-answer-column"]');
const row = last.locator('[data-slot="coach-answer-actions"]');
const left = row.locator('[data-slot="coach-answer-actions-left"]');
const meta = row.locator('[data-slot="coach-answer-meta"]');
await expect(row).toHaveCount(1);
await expect(meta).toContainText("1,234 tokens");
await expect(row).toHaveCSS("opacity", "1");
const [bubbleBox, columnBox, rowBox, leftBox, metaBox] = await Promise.all(
[bubble, column, row, left, meta].map(box),
);
expect(
Math.abs(
leftBox.y + leftBox.height / 2 - (metaBox.y + metaBox.height / 2),
),
"left group and meta share one line",
).toBeLessThanOrEqual(2);
expect(rowBox.height, "the row is one line").toBeLessThanOrEqual(44);
expect(Math.abs(leftBox.x - bubbleBox.x)).toBeLessThanOrEqual(1);
expect(metaBox.x + metaBox.width).toBeLessThanOrEqual(
columnBox.x + columnBox.width + 1,
);
await expect(left.locator('[data-slot="coach-message-time"]')).toHaveCount(
1,
);
// 2. The chips in the answer's column, at the answer's size.
const chips = last.locator('[data-slot="coach-follow-up-chips"]');
await expect(chips.locator("[data-follow-up-id]")).toHaveCount(2);
const chipsBox = await box(chips);
expect(Math.abs(chipsBox.x - bubbleBox.x)).toBeLessThanOrEqual(1);
expect(chipsBox.y).toBeGreaterThanOrEqual(rowBox.y + rowBox.height - 1);
const answerSize = await bubble.evaluate(
(el) => getComputedStyle(el).fontSize,
);
for (const chip of await chips.locator("[data-follow-up-id]").all()) {
await expect(chip).toHaveCSS("font-size", answerSize);
const chipBox = await box(chip);
if (desktop) expect(chipBox.height).toBeLessThanOrEqual(36);
else expect(chipBox.height).toBeGreaterThanOrEqual(44);
}
// 3. Copy and remember share the question's one row.
const question = page.locator('[data-slot="coach-bubble-user"]').last();
if (desktop) await question.hover();
const userRow = question.locator('[data-slot="coach-user-actions"]');
const remember = userRow.locator('[data-slot="coach-remember-message"]');
const copy = userRow.locator('[data-slot="coach-copy-message"]');
await expect(remember).toHaveAttribute("aria-label", "Remember");
const [rememberBox, copyBox] = await Promise.all([remember, copy].map(box));
expect(
Math.abs(
rememberBox.y +
rememberBox.height / 2 -
(copyBox.y + copyBox.height / 2),
),
).toBeLessThanOrEqual(1);
// The question's column holds the bubble and its one row, nothing else.
expect(
await userRow.evaluate((el) => el.parentElement?.childElementCount),
).toBe(2);
// 4. Away from the end, the button appears and brings the thread back.
const jump = page.locator('[data-slot="coach-scroll-to-bottom"]');
await expect(jump).toHaveAttribute("data-visible", "false");
await expect(jump).toHaveCSS("opacity", "0");
await thread.evaluate((el) => el.scrollTo({ top: 0, behavior: "auto" }));
await expect(jump).toHaveAttribute("data-visible", "true");
await expect(jump).toHaveCSS("opacity", "1");
await shoot(page, testInfo, `scrolled-up-${viewport.label}`);
const jumpBox = await box(jump);
const threadBox = await box(thread);
expect(
Math.abs(
jumpBox.x + jumpBox.width / 2 - (threadBox.x + threadBox.width / 2),
),
"centred over the thread",
).toBeLessThanOrEqual(1);
await jump.click();
await expect
.poll(() =>
thread.evaluate(
(el) => el.scrollHeight - el.scrollTop - el.clientHeight,
),
)
.toBeLessThanOrEqual(2);
await expect(jump).toHaveAttribute("data-visible", "false");
await expect(jump).toHaveAttribute("tabindex", "-1");
await expect(page.locator("#coach-composer-textarea")).toBeFocused();
});
});
+4 -3
View File
@@ -550,10 +550,11 @@ test.describe("Coach dialog", () => {
await expect(figure).toBeVisible();
await expect(table).toBeHidden();
// The method line sits in the evidence disclosure.
await firstBubble.locator('[data-slot="coach-evidence-summary"]').click();
// The method line closes the open steps list.
await expect(
firstBubble.locator('[data-slot="coach-method-line"]'),
steps.locator(
'[data-slot="coach-turn-steps-panel"] [data-slot="coach-method-line"]',
),
).toContainText(TURN_ONE_METHOD.text);
// 3. A follow-up chip sends which chip it was.
+642
View File
@@ -0,0 +1,642 @@
import AxeBuilder from "@axe-core/playwright";
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";
/**
* 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 at the
* panel's bottom right.
*
* Contracts under test, at 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.
* 2. Escape inside the docked panel closes it and returns focus to the
* toggle.
* 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.
* 4. The gear opens the quick settings as a popover; Escape closes it and
* focus returns to the gear. `/coach?settings=data` opens it on "What I
* 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 16 px from the
* right edge; 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.
*
* Every Coach route is stubbed; the provider chain and provider config are
* stubbed too, so the model section renders the same on every machine.
* Screenshots are attached to the report (no pixel baselines).
*/
const NOW = Date.now();
const HOUR = 60 * 60 * 1000;
interface Conversation {
id: string;
title: string;
updatedAt: string;
}
function conversations(): Conversation[] {
return [
{
id: "frame-bp",
title: "Blood pressure this week",
updatedAt: new Date(NOW - HOUR).toISOString(),
},
{
id: "frame-sleep",
title: "Sleep since May",
updatedAt: new Date(NOW - 26 * HOUR).toISOString(),
},
{
id: "frame-weight",
title: "Weight over the summer",
updatedAt: new Date(NOW - 10 * 24 * HOUR).toISOString(),
},
];
}
const ANSWER =
"Your blood pressure held a steady band this week. The mornings ran a little higher than the evenings, which is common and nothing that stands out on its own.";
function fulfilJson(route: Route, data: unknown, status = 200) {
return route.fulfill({
status,
contentType: "application/json",
body: JSON.stringify({ data, error: null }),
});
}
interface CoachMock {
patches: Array<{ id: string; title: string }>;
deletes: string[];
}
async function mockCoach(page: Page): Promise<CoachMock> {
const state = conversations();
const mock: CoachMock = { patches: [], deletes: [] };
const dto = (c: Conversation) => ({
id: c.id,
title: c.title,
createdAt: c.updatedAt,
updatedAt: c.updatedAt,
messageCount: 2,
fenced: false,
attachments: [],
documentTitle: null,
});
await page.route(
/\/api\/insights\/chat(?:\/[^?]+)?(?:\?.*)?$/,
async (route: Route, request: Request) => {
const url = new URL(request.url());
const match = url.pathname.match(/^\/api\/insights\/chat\/([^/]+)$/);
if (match) {
const id = decodeURIComponent(match[1]);
const found = state.find((c) => c.id === id);
if (request.method() === "PATCH") {
const { title } = request.postDataJSON() as { title: string };
mock.patches.push({ id, title });
if (found) found.title = title;
return fulfilJson(route, { id, title });
}
if (request.method() === "DELETE") {
mock.deletes.push(id);
const idx = state.findIndex((c) => c.id === id);
if (idx >= 0) state.splice(idx, 1);
return fulfilJson(route, { deleted: true });
}
if (!found) return fulfilJson(route, null, 404);
return fulfilJson(route, {
...dto(found),
attachmentCount: 0,
summary: null,
messages: [
{
id: `${id}-u`,
role: "user",
content: `Tell me about ${found.title.toLowerCase()}.`,
createdAt: found.updatedAt,
metricSource: null,
providerType: null,
promptVersion: null,
tokensUsed: null,
model: null,
},
{
id: `${id}-a`,
role: "assistant",
content: ANSWER,
createdAt: found.updatedAt,
metricSource: null,
providerType: "mock",
promptVersion: "e2e",
tokensUsed: 640,
model: "mock",
},
],
});
}
if (url.pathname.startsWith("/api/insights/chat/")) {
return fulfilJson(route, { results: [] });
}
const q = (url.searchParams.get("q") ?? "").toLowerCase();
return fulfilJson(route, {
conversations: state
.filter((c) => c.title.toLowerCase().includes(q))
.map(dto),
nextCursor: null,
});
},
);
await page.route("**/api/insights/coach/nudge-status*", (route) =>
fulfilJson(route, { nudgedAt: null, unread: false }),
);
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, {
activeProvider: "local",
cachedActiveProvider: "local",
configuredChain: [
{ providerType: "local", enabled: true, available: true },
{ providerType: "admin-openai", enabled: true, available: true },
],
})
: fulfilJson(route, { saved: true }),
);
await page.route("**/api/user/ai-provider", (route) =>
route.request().method() === "GET"
? fulfilJson(route, {
provider: "LOCAL",
model: "qwen2.5",
baseUrl: "http://localhost:11434/v1",
hasAnthropicKey: false,
anthropicKeyPreview: null,
hasLocalKey: false,
hasOpenaiKey: false,
openaiKeyPreview: null,
compatBaseUrl: null,
compatModel: null,
hasCompatKey: false,
responseTimeoutSeconds: null,
localReasoningEffort: null,
compatReasoningEffort: null,
})
: fulfilJson(route, { updated: true }),
);
return mock;
}
const toggle = (page: Page) => page.locator('[data-slot="coach-panel-toggle"]');
const panel = (page: Page) =>
page.locator('[data-slot="coach-conversations-panel"]');
/** Width of the reading column: the thread's capped inner column. */
async function columnWidths(page: Page) {
const thread = page.locator('[data-slot="coach-message-thread"] > *').first();
const composer = page.locator('[data-slot="coach-page-composer"] > div');
const t = await thread.boundingBox();
const c = await composer.boundingBox();
expect(t, "thread column").not.toBeNull();
expect(c, "composer column").not.toBeNull();
return { thread: t!.width, composer: c!.width, threadX: t!.x };
}
async function expectNoSidewaysScroll(page: Page) {
const overflow = await page.evaluate(() => {
const doc = document.scrollingElement!;
const main = document.getElementById("main-content")!;
return {
doc: doc.scrollWidth - doc.clientWidth,
main: main.scrollWidth - main.clientWidth,
};
});
expect(overflow.doc).toBeLessThanOrEqual(0);
expect(overflow.main).toBeLessThanOrEqual(0);
}
async function shot(page: Page, testInfo: TestInfo, name: string) {
const path = testInfo.outputPath(`${name}.png`);
await page.screenshot({ path });
await testInfo.attach(name, { path, contentType: "image/png" });
const dir = process.env.COACH_SHOTS_DIR;
if (dir) await page.screenshot({ path: `${dir}/${name}.png` });
}
/**
* Record every mount of the conversations sheet from the first byte on.
* Reads the added nodes rather than the live DOM, so a sheet that mounts and
* unmounts again before the observer's callback runs is still counted.
*/
async function watchSheetMounts(page: Page) {
await page.addInitScript(() => {
const w = window as unknown as { __coachSheetSeen?: boolean };
w.__coachSheetSeen = false;
const sheet = '[role="dialog"][data-slot="coach-conversations-panel"]';
new MutationObserver((records) => {
for (const record of records) {
for (const node of record.addedNodes) {
if (
node instanceof Element &&
(node.matches(sheet) || node.querySelector(sheet))
) {
w.__coachSheetSeen = true;
}
}
}
}).observe(document, { childList: true, subtree: true });
});
}
async function sheetMounted(page: Page): Promise<boolean> {
return page.evaluate(
() =>
(window as unknown as { __coachSheetSeen?: boolean }).__coachSheetSeen ===
true,
);
}
async function openCoach(page: Page, path = "/coach?c=frame-bp") {
await page.goto(path, { waitUntil: "domcontentloaded" });
await expect(
page.locator('[data-slot="coach-message-thread"]').getByText(ANSWER),
).toBeVisible({ timeout: 15_000 });
}
async function waitForWidth(locator: Locator, width: number) {
await expect
.poll(async () => Math.round((await locator.boundingBox())?.width ?? -1))
.toBe(width);
}
test.describe("Coach page frame", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeEach(async ({ page }, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"each case sets its own viewport; the desktop project runs them all",
);
await serveAiBlock(page, aiBlockAvailable());
await page.emulateMedia({ reducedMotion: "reduce" });
});
for (const width of [1440, 1280]) {
test(`${width}: the panel docks, toggles, persists, and the reading column never changes width`, async ({
page,
}, testInfo) => {
await page.setViewportSize({ width, height: 900 });
await mockCoach(page);
await openCoach(page);
// 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(
"aria-controls",
"coach-conversations-panel",
);
const open = await columnWidths(page);
expect(open.thread).toBeCloseTo(672, 0);
expect(Math.abs(open.composer - open.thread)).toBeLessThanOrEqual(1);
// The panel sits right of the column, never over it.
const aside = (await panel(page).boundingBox())!;
expect(aside.x).toBeGreaterThanOrEqual(open.threadX + open.thread);
await expectNoSidewaysScroll(page);
await shot(page, testInfo, `coach-frame-${width}-open`);
await toggle(page).click();
await expect(panel(page)).toHaveAttribute("data-state", "closed");
await waitForWidth(panel(page), 0);
await expect(toggle(page)).toHaveAttribute("aria-expanded", "false");
const closed = await columnWidths(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-search"]'),
).not.toBeInViewport();
await expectNoSidewaysScroll(page);
await shot(page, testInfo, `coach-frame-${width}-closed`);
// Remembered across a reload.
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();
await expect(panel(page)).toHaveAttribute("data-state", "open");
await panel(page).locator('[data-slot="coach-history-search"]').focus();
await page.keyboard.press("Escape");
await expect(panel(page)).toHaveAttribute("data-state", "closed");
await expect(toggle(page)).toBeFocused();
});
}
test("1440: picking a row opens it and New chat clears it, both in the URL", async ({
page,
}) => {
await page.setViewportSize({ width: 1440, height: 900 });
await mockCoach(page);
await openCoach(page);
const rows = panel(page).locator('[data-slot="coach-history-select"]');
await expect(rows).toHaveCount(3);
await expect(panel(page).locator('[data-group="today"]')).toContainText(
"Blood pressure this week",
);
await expect(panel(page).locator('[data-group="yesterday"]')).toContainText(
"Sleep since May",
);
await expect(rows.nth(0)).toHaveAttribute("aria-current", "true");
await rows.nth(1).click();
await expect(page).toHaveURL(/\/coach\?c=frame-sleep$/);
await expect(rows.nth(1)).toHaveAttribute("aria-current", "true");
const fab = panel(page).locator('[data-slot="coach-panel-new-chat"]');
const fabBox = (await fab.boundingBox())!;
const asideBox = (await panel(page).boundingBox())!;
expect(asideBox.x + asideBox.width - (fabBox.x + fabBox.width)).toBeCloseTo(
16,
0,
);
await fab.click();
await expect(page).toHaveURL(/\/coach$/);
await expect(
page.locator('[data-slot="coach-input-textarea"]'),
).toBeVisible();
await expect(panel(page).locator('[aria-current="true"]')).toHaveCount(0);
});
test("1440: rename by keyboard and delete with undo from the row menu", async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 1440, height: 900 });
const mock = await mockCoach(page);
await openCoach(page);
const row = panel(page)
.locator('[data-slot="coach-history-item"]')
.filter({ hasText: "Sleep since May" });
const menu = row.locator('[data-slot="coach-history-row-menu"]');
await expect(menu).toHaveAttribute(
"aria-label",
"Actions for Sleep since May",
);
// Keyboard: focus the menu button, open it, pick Rename.
await menu.focus();
await page.keyboard.press("Enter");
await page.getByRole("menuitem", { name: "Rename" }).click();
const input = row.locator('[data-slot="coach-conversation-rename-input"]');
await expect(input).toBeFocused();
await input.fill("Discard this");
await page.keyboard.press("Escape");
await expect(input).toHaveCount(0);
await expect(panel(page)).toHaveAttribute("data-state", "open");
await expect(row).toContainText("Sleep since May");
await expect(menu).toBeFocused();
expect(mock.patches).toHaveLength(0);
await page.keyboard.press("Enter");
await page.getByRole("menuitem", { name: "Rename" }).click();
await expect(input).toBeFocused();
await input.fill("Sleep since spring");
await page.keyboard.press("Enter");
const renamed = panel(page)
.locator('[data-slot="coach-history-item"]')
.filter({ hasText: "Sleep since spring" });
await expect(renamed).toHaveCount(1);
await expect(input).toHaveCount(0);
expect(mock.patches).toEqual([
{ id: "frame-sleep", title: "Sleep since spring" },
]);
// Delete, then Undo: the row returns and no DELETE leaves.
await renamed.hover();
await renamed.locator('[data-slot="coach-history-row-menu"]').click();
await page.getByRole("menuitem", { name: "Delete" }).click();
await expect(renamed).toHaveCount(0);
await shot(page, testInfo, "coach-frame-1440-delete-toast");
await page.getByRole("button", { name: "Undo" }).click();
await expect(renamed).toHaveCount(1);
await page.waitForTimeout(6_500);
expect(mock.deletes).toEqual([]);
// Delete left alone commits once, after the undo window.
await renamed.hover();
await renamed.locator('[data-slot="coach-history-row-menu"]').click();
await page.getByRole("menuitem", { name: "Delete" }).click();
await expect(renamed).toHaveCount(0);
await expect
.poll(() => mock.deletes, { timeout: 10_000 })
.toEqual(["frame-sleep"]);
});
test("1440: the gear opens the quick settings as a popover; Escape returns to the gear", async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 1440, height: 900 });
await mockCoach(page);
await openCoach(page);
const gear = panel(page).locator('[data-slot="coach-settings"]');
await gear.click();
const popover = page.locator('[data-slot="coach-settings-popover"]');
await expect(popover).toBeVisible();
await expect(popover).toHaveAttribute("role", "dialog");
await expect(
popover.locator('[data-slot="coach-quick-provider"]'),
).toHaveValue("local");
await expect(
popover.locator('[data-slot="coach-quick-model"]'),
).toHaveValue("qwen2.5");
await expect(
popover.locator('[data-slot="coach-sources-window"]'),
).toBeVisible();
// The popover stays inside the viewport.
const box = (await popover.boundingBox())!;
expect(box.x + box.width).toBeLessThanOrEqual(1440);
expect(box.y + box.height).toBeLessThanOrEqual(900);
await shot(page, testInfo, "coach-frame-1440-settings");
await page.keyboard.press("Escape");
await expect(popover).toHaveCount(0);
await expect(gear).toBeFocused();
// Escape on the popover does not also close the panel.
await expect(panel(page)).toHaveAttribute("data-state", "open");
});
test("1440: /coach?settings=data opens the settings on what the Coach sees", async ({
page,
}) => {
await page.setViewportSize({ width: 1440, height: 900 });
await mockCoach(page);
// A closed panel is revealed for the deep link.
await page.addInitScript(() => {
window.localStorage.setItem("healthlog.coach.panelOpen", "false");
});
// The hydration render does not know the viewport yet; the phone sheet
// must not mount on a desktop even for a moment.
await watchSheetMounts(page);
await page.goto("/coach?settings=data", { waitUntil: "domcontentloaded" });
const popover = page.locator('[data-slot="coach-settings-popover"]');
await expect(popover).toBeVisible({ timeout: 15_000 });
await expect(panel(page)).toHaveAttribute("data-state", "open");
expect(
await sheetMounted(page),
"the phone sheet flashed on a desktop deep link",
).toBe(false);
await expect(
popover.locator('[data-slot="coach-settings-data"]'),
).toBeFocused();
await expect(page).toHaveURL(/\/coach$/);
await page.keyboard.press("Escape");
await expect(popover).toHaveCount(0);
});
for (const theme of ["light", "dark"] as const) {
test(`1440 ${theme}: axe finds nothing on the panel or the quick settings`, async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.emulateMedia({ colorScheme: theme, reducedMotion: "reduce" });
await page.addInitScript((value: string) => {
window.localStorage.setItem("healthlog-theme", value);
}, theme);
await mockCoach(page);
await openCoach(page);
await expect(page.locator("html")).toHaveClass(
new RegExp(`(^|\\s)${theme}(\\s|$)`),
);
await expect(panel(page)).toHaveAttribute("data-state", "open");
const scan = async (selector: string, label: string) => {
const result = await new AxeBuilder({ page })
.include(selector)
.withTags([
"wcag2a",
"wcag2aa",
"wcag21a",
"wcag21aa",
"best-practice",
])
.analyze();
expect(
result.violations.map((v) => ({
id: v.id,
nodes: v.nodes.map((n) => n.target.join(" ")),
})),
`${label} accessibility violations`,
).toEqual([]);
};
await scan('[data-slot="coach-conversations-panel"]', "panel");
await shot(page, testInfo, `coach-frame-1440-${theme}-panel`);
await panel(page).locator('[data-slot="coach-settings"]').click();
const popover = page.locator('[data-slot="coach-settings-popover"]');
await expect(
popover.locator('[data-slot="coach-quick-provider"]'),
).toBeVisible();
await scan('[data-slot="coach-settings-popover"]', "settings");
await shot(page, testInfo, `coach-frame-1440-${theme}-settings`);
});
}
test("390: /coach?settings=data opens the sheet and the settings on what the Coach sees", async ({
page,
}) => {
await page.setViewportSize({ width: 390, height: 844 });
await mockCoach(page);
await watchSheetMounts(page);
await page.goto("/coach?settings=data", { waitUntil: "domcontentloaded" });
// The settings bottom sheet opens over the conversations sheet and
// hides it from the accessibility tree, so the sheet is found by slot.
await expect(page.locator('[data-slot="coach-settings-data"]')).toBeVisible(
{ timeout: 15_000 },
);
await expect(
page.locator('[role="dialog"][data-slot="coach-conversations-panel"]'),
).toBeVisible();
// The positive control for the desktop case: the same watcher sees the
// sheet mount where it belongs.
expect(await sheetMounted(page)).toBe(true);
await expect(page).toHaveURL(/\/coach$/);
});
test("390: the panel is a sheet, the gear a bottom sheet, and nothing scrolls sideways", async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 390, height: 844 });
await mockCoach(page);
await openCoach(page);
// Below 1280 px the panel waits to be asked.
await expect(panel(page)).toHaveCount(0);
await expect(toggle(page)).toHaveAttribute("aria-expanded", "false");
await expectNoSidewaysScroll(page);
await shot(page, testInfo, "coach-frame-390-thread");
await toggle(page).click();
const sheet = page.getByRole("dialog", { name: "Conversations" });
await expect(sheet).toBeVisible();
await expect(toggle(page)).toHaveAttribute("aria-expanded", "true");
const fab = sheet.locator('[data-slot="coach-panel-new-chat"]');
const fabBox = (await fab.boundingBox())!;
expect(390 - (fabBox.x + fabBox.width)).toBeCloseTo(16, 0);
expect(fabBox.width).toBeGreaterThanOrEqual(44);
await shot(page, testInfo, "coach-frame-390-panel");
await page.keyboard.press("Escape");
await expect(sheet).toHaveCount(0);
await expect(toggle(page)).toBeFocused();
// The gear in the sheet opens a bottom sheet over it.
await toggle(page).click();
await expect(sheet).toBeVisible();
const gear = sheet.locator('[data-slot="coach-settings"]');
await gear.click();
const settings = page.locator('[data-slot="responsive-sheet-content"]');
await expect(settings).toHaveAttribute("data-variant", "sheet");
await expect(
settings.locator('[data-slot="coach-quick-provider"]'),
).toBeVisible();
const box = (await settings.boundingBox())!;
expect(Math.round(box.y + box.height)).toBe(844);
await shot(page, testInfo, "coach-frame-390-settings");
await page.keyboard.press("Escape");
await expect(settings).toHaveCount(0);
await expect(gear).toBeFocused();
await expect(sheet).toBeVisible();
// Picking a conversation closes the sheet and opens the thread.
await sheet
.locator('[data-slot="coach-history-select"]')
.filter({ hasText: "Weight over the summer" })
.click();
await expect(sheet).toHaveCount(0);
await expect(page).toHaveURL(/\/coach\?c=frame-weight$/);
await expectNoSidewaysScroll(page);
});
});
+10 -4
View File
@@ -289,7 +289,7 @@ test.describe("Coach result tables and charts", () => {
});
for (const theme of ["light", "dark"] as const) {
test(`theme=${theme}: chart first, table on toggle, data used in the evidence`, async ({
test(`theme=${theme}: chart first, table on toggle, data used in the steps list`, async ({
page,
}) => {
await useTheme(page, theme);
@@ -342,12 +342,18 @@ test.describe("Coach result tables and charts", () => {
await table.locator('[data-slot="coach-result-view-chart"]').click();
await expect(figure).toBeVisible();
// The table the answer only used waits in the evidence disclosure.
// The table the answer only used waits in the open steps list.
await expect(
bubble.locator('[data-slot="coach-result-table"][data-ref="r2"]'),
).toBeHidden();
await bubble.locator('[data-slot="coach-evidence-summary"]').click();
const dataUsed = bubble.locator('[data-slot="coach-data-used"]');
const stepsToggle = bubble.locator(
'[data-slot="coach-turn-steps-toggle"]',
);
await expect(stepsToggle).toHaveAttribute("aria-expanded", "false");
await stepsToggle.click();
const dataUsed = bubble.locator(
'[data-slot="coach-turn-steps-panel"] [data-slot="coach-data-used"]',
);
await expect(dataUsed).toContainText("Data used (1)");
const workouts = dataUsed.locator(
'[data-slot="coach-result-table"][data-ref="r2"]',
+3
View File
@@ -115,6 +115,9 @@ test.describe("authenticated dashboard render", () => {
score: { value: 82, band: "green", delta: 2 },
topSignal: null,
briefingLead: "Your week is trending steady.",
lead: { text: "Your week is trending steady.", source: "briefing" },
today: [],
restMode: null,
line: "Your week is trending steady.",
worthALook: [
{
+131
View File
@@ -0,0 +1,131 @@
/**
* Document vault — the procedure filter.
*
* The facet beside the condition filter: it lists the procedures that hold a
* document, labelled the way the visits page heads them, narrows the list to
* one procedure's documents through the real `encounterId` filter, writes the
* choice to the URL (`?encounter=`), comes back from a deep link, and clears
* with the one clear control. Run at a 1440 px desktop and a 390 px phone,
* where the filter row must stay a single line with no sideways scroll.
*
* Fixture: `ensureProcedureFixture` (two procedures over a namespaced trio of
* documents), upserted before every test because the visits spec clears the
* account's visits; the dates sit far outside that spec's ±7-day window.
*/
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
ensureProcedureFixture,
ensureVaultFixture,
PROCEDURE_DOC_PREFIX,
PROCEDURE_KNEE_ID,
} from "./setup/vault-fixture";
function card(page: Page, n: number) {
return page
.getByRole("button", {
name: `Open ${PROCEDURE_DOC_PREFIX} ${String(n).padStart(3, "0")}`,
})
.first();
}
/** The filter row is one line and never scrolls sideways. */
async function expectOneRow(page: Page) {
const bar = page.locator('[data-slot="document-filter-bar"]');
const row = bar.locator("> div").first();
const box = await row.boundingBox();
expect(box).not.toBeNull();
expect(box!.height).toBeLessThan(48);
const overflow = await page.evaluate(
() => document.documentElement.scrollWidth - window.innerWidth,
);
expect(overflow).toBeLessThanOrEqual(0);
}
async function shot(page: Page, name: string, isMobile: boolean) {
const dir = process.env.E2E_SHOT_DIR;
if (!dir) return;
await page.screenshot({
path: `${dir}/${name}-${isMobile ? "390" : "1440"}.png`,
animations: "disabled",
});
}
test.describe("document vault — procedure filter", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeAll(async () => {
await ensureVaultFixture();
});
test.beforeEach(async ({ page, isMobile }) => {
await ensureProcedureFixture();
await page.setViewportSize(
isMobile ? { width: 390, height: 844 } : { width: 1440, height: 900 },
);
});
test("picks a procedure, narrows to its documents, and clears", async ({
page,
isMobile,
}) => {
await page.goto(`/documents?q=${PROCEDURE_DOC_PREFIX}`);
await expect(card(page, 3)).toBeVisible();
await expect(card(page, 1)).toBeVisible();
const trigger = page.locator('[data-slot="document-procedure-filter"]');
await expect(trigger).toBeVisible();
await expect(trigger).toHaveAttribute("aria-label", "All procedures");
await expectOneRow(page);
await shot(page, "procedure-filter-idle", isMobile);
await trigger.click();
const menu = page.getByRole("menu");
await expect(menu).toBeVisible();
await expect(menu).toContainText("Procedures and surgeries");
// Headed like the visits page, with the body site and side beneath.
const knee = menu.getByRole("menuitemcheckbox", {
name: /Knee arthroscopy/,
});
await expect(knee).toContainText("Knee · Left");
await expect(
menu.getByRole("menuitemcheckbox", { name: /Appendectomy/ }),
).toBeVisible();
await shot(page, "procedure-filter-menu", isMobile);
await knee.click();
await expect(page).toHaveURL(new RegExp(`encounter=${PROCEDURE_KNEE_ID}`));
await expect(card(page, 1)).toBeVisible();
await expect(card(page, 2)).toBeVisible();
await expect(card(page, 3)).toHaveCount(0);
await expect(trigger).toHaveAttribute("aria-label", "Knee arthroscopy");
// Picked: the label shows at every width, phone included.
await expect(trigger).toContainText("Knee arthroscopy");
await expectOneRow(page);
// The search field never collapses under the facets.
const search = await page
.getByRole("searchbox", { name: "Search documents" })
.boundingBox();
expect(search!.width).toBeGreaterThanOrEqual(48);
await shot(page, "procedure-filter-active", isMobile);
await page.getByRole("button", { name: "Clear filters" }).click();
await expect(page).not.toHaveURL(/encounter=/);
await expect(trigger).toHaveAttribute("aria-label", "All procedures");
});
test("a deep link lands filtered and the facet names the procedure", async ({
page,
}) => {
await page.goto(
`/documents?q=${PROCEDURE_DOC_PREFIX}&encounter=${PROCEDURE_KNEE_ID}`,
);
await expect(card(page, 2)).toBeVisible();
await expect(card(page, 3)).toHaveCount(0);
await expect(
page.locator('[data-slot="document-procedure-filter"]'),
).toHaveAttribute("aria-label", "Knee arthroscopy");
});
});
+75
View File
@@ -550,6 +550,81 @@ export async function seedNamespaceDocs(
}
}
/** The procedure-filter fixture: two procedures, each with its own files. */
export const PROCEDURE_KNEE_ID = "e2eprocknee0000000000001";
export const PROCEDURE_APPENDIX_ID = "e2eprocappx0000000000001";
export const PROCEDURE_DOC_PREFIX = "procfilter";
/**
* Seed the procedure-filter fixture: three namespaced documents and two
* procedures — a knee arthroscopy (reason + body site + side) holding two of
* them, and an appendectomy holding the third. Dated far from the MRT report
* so the visits spec's ±7-day window never sees them. Idempotent: the rows
* upsert by fixed id and come back live, so a `resetEncounters` from the visits
* spec between runs is repaired by the next call.
*/
export async function ensureProcedureFixture(): Promise<void> {
const url = process.env.DATABASE_URL;
if (!url) throw new Error("[vault-fixture] DATABASE_URL is not set");
await seedNamespaceDocs(PROCEDURE_DOC_PREFIX, 3);
const pool = new pg.Pool({ connectionString: url });
try {
const userId = await getUserId(pool);
const procedures = [
{
id: PROCEDURE_KNEE_ID,
at: "2024-03-12T08:00:00Z",
reason: "Knee arthroscopy",
site: "Knee",
side: "LEFT",
docs: [0, 1],
},
{
id: PROCEDURE_APPENDIX_ID,
at: "2023-06-20T08:00:00Z",
reason: "Appendectomy",
site: null,
side: null,
docs: [2],
},
];
for (const p of procedures) {
await pool.query(
`INSERT INTO encounters
(id, user_id, occurred_at, status, kind, reason_encrypted,
body_site_encrypted, laterality, created_at, updated_at)
VALUES ($1, $2, $3, 'DONE', 'PROCEDURE', $4, $5, $6::laterality, NOW(), NOW())
ON CONFLICT (id) DO UPDATE SET
occurred_at = EXCLUDED.occurred_at, kind = 'PROCEDURE',
status = 'DONE', reason_encrypted = EXCLUDED.reason_encrypted,
body_site_encrypted = EXCLUDED.body_site_encrypted,
laterality = EXCLUDED.laterality, deleted_at = NULL,
updated_at = NOW()`,
[
p.id,
userId,
p.at,
encryptStringToBytes(p.reason),
p.site ? encryptStringToBytes(p.site) : null,
p.side,
],
);
for (const n of p.docs) {
const docId = `e2e${PROCEDURE_DOC_PREFIX}${String(n).padStart(8, "0")}`;
await pool.query(
`INSERT INTO encounter_document_links
(id, encounter_id, document_id, user_id, created_at)
VALUES ($1, $2, $3, $4, NOW())
ON CONFLICT (encounter_id, document_id) DO NOTHING`,
[`${p.id.slice(0, 16)}lnk${n}`, p.id, docId, userId],
);
}
}
} finally {
await pool.end();
}
}
/**
* v1.28 — seed the clinician-share document trio (see the `SHARE_*_DOC_ID`
* constants). Enables the vault module for the e2e user and upserts one
+319
View File
@@ -0,0 +1,319 @@
import { mkdirSync } from "node:fs";
import { join } from "node:path";
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
mockDashboardSnapshot,
POPULATED_SUMMARIES,
} from "./utils/mock-dashboard-snapshot";
import type { DailyDigest } from "@/lib/daily/digest";
import { DIGEST_AI_AVAILABLE } from "@/__tests__/helpers/ai-capability-fixtures";
/**
* The Today overview on the dashboard hero, at a desktop and a phone width.
*
* What it proves is geometry, because that is what the unit suites cannot
* see: the facts sit beside the ring on a wide screen and above it on a
* phone, a phone shows four facts and a wide screen five, nothing pushes the
* page sideways, the hero keeps the width of the cards around it, and the
* ring keeps its size. Each state is also captured as an element screenshot
* under `test-results/today-overview/` for a visual check.
*
* The digests are what the server would publish for each state. They carry
* the pre-overview fields too (`briefingLead`, `line`), with the values the
* old server sent, so the same fixtures render the previous hero on an older
* build for a before/after comparison.
*
* Assertions anchor on `data-slot` / `data-kind`, never on visible text.
*/
test.use({ storageState: STORAGE_STATE_PATH });
const SHOTS = join(process.cwd(), "test-results", "today-overview");
const VIEWPORTS = [
{ name: "desktop", width: 1440, height: 900 },
{ name: "mobile", width: 390, height: 844 },
] as const;
const BASE: DailyDigest = {
generatedAt: new Date().toISOString(),
ai: DIGEST_AI_AVAILABLE,
phase: "final",
sleepPending: false,
score: {
value: 94,
band: "green",
delta: null,
deltaReason: "first_eligibility_window",
steadyWeeks: 4,
},
topSignal: null,
briefingLead: null,
lead: null,
today: [],
restMode: null,
line: "Your health score today is 94.",
worthALook: [],
justIn: null,
reactionLine: null,
};
const FACTS: DailyDigest["today"] = [
{
kind: "medications",
label: "Medications",
value: "1 of 3 taken",
href: "/medications",
moduleKey: "medications",
},
{
kind: "appointment",
label: "Appointment",
value: "Tomorrow 09:30, Routine visit",
href: "/checkups",
},
{
kind: "sleep",
label: "Last night",
value: "7h 20m, close to your usual",
href: "/insights/sleep",
moduleKey: "sleep",
},
{
kind: "vitals",
label: "Vitals",
value: "6 checked, all in your range",
href: "/insights",
},
{
kind: "cycle",
label: "Cycle",
value: "Follicular, day 12",
href: "/cycle",
moduleKey: "cycle",
},
];
const STATES: Record<string, DailyDigest> = {
// AI on. The old server led with the briefing's first sentence, which was
// the greeting.
"ai-on": {
...BASE,
briefingLead: "Good morning.",
line: "Good morning.",
topSignal: {
sourceMetric: "bp",
tone: "good",
headline: "Blood pressure is sitting in the optimal band",
nudge: "",
delta: "↓ ~10 mmHg systolic vs the start of the window",
},
lead: {
text: "Your latest blood pressure is sitting in the optimal band.",
source: "briefing",
},
today: FACTS,
worthALook: [
{
kind: "dose_window",
title: "Medication due",
body: "Ramipril is due soon.",
status: "info",
actions: [
{
labelKey: "daily.action.logDose",
intent: "dose.log",
href: "/medications",
},
],
moduleKey: "medications",
},
],
},
// AI off. The old server had only the score sentence, which the hero then
// removed again, leaving the ring and "Nothing needs your attention".
"ai-off": {
...BASE,
ai: {
...DIGEST_AI_AVAILABLE,
briefing: {
available: false,
reason: "no_provider",
onDeviceAllowed: false,
},
},
lead: {
text: "Resting heart rate is at 61 bpm, above your usual range of 50 to 58 bpm.",
source: "signal",
},
today: [FACTS[0], FACTS[2], FACTS[4]],
},
// A quiet day: one calm sentence and no facts beyond it.
quiet: {
...BASE,
lead: {
text: "All 6 of your latest vitals sit inside their usual range.",
source: "signal",
},
},
};
async function openDashboard(page: Page, digest: DailyDigest) {
await mockDashboardSnapshot(page, { summaries: POPULATED_SUMMARIES });
await page.route(/\/api\/daily\/digest(\?|$)/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: digest, error: null }),
}),
);
await page.goto("/", { waitUntil: "domcontentloaded" });
const hero = page.locator('[data-slot="today-hero"]');
await expect(hero).toBeVisible({ timeout: 15_000 });
await expect(page.locator('[data-slot="dashboard-tile-strip"]')).toBeVisible({
timeout: 15_000,
});
return hero;
}
test.describe("Today overview geometry", () => {
test.beforeEach(({}, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"viewport-driven spec; desktop project only",
);
});
for (const viewport of VIEWPORTS) {
for (const [state, digest] of Object.entries(STATES)) {
test(`${state} at ${viewport.width}px`, async ({ page }) => {
await page.setViewportSize(viewport);
const hero = await openDashboard(page, digest);
mkdirSync(SHOTS, { recursive: true });
await hero.screenshot({
path: join(SHOTS, `${viewport.name}-${state}.png`),
});
// Nothing pushes the page sideways.
const overflow = await page.evaluate(
() => document.documentElement.scrollWidth - window.innerWidth,
);
expect(overflow).toBeLessThanOrEqual(0);
// The hero spans the same column as the tile strip below it.
const heroBox = (await hero.boundingBox())!;
const stripBox = (await page
.locator('[data-slot="dashboard-tile-strip"]')
.boundingBox())!;
expect(Math.abs(heroBox.x - stripBox.x)).toBeLessThanOrEqual(1);
expect(Math.abs(heroBox.width - stripBox.width)).toBeLessThanOrEqual(1);
// The ring keeps its size: the same 168 px dial as before.
const ring = hero.locator('[data-slot="score-ring"]').first();
const ringBox = (await ring.boundingBox())!;
expect(Math.round(ringBox.width)).toBe(168);
expect(Math.round(ringBox.height)).toBe(168);
const lead = hero.locator('[data-slot="today-hero-lead"]');
await expect(lead).toBeVisible();
await expect(lead).toHaveAttribute("data-source", digest.lead!.source);
const leadBox = (await lead.boundingBox())!;
const facts = hero.locator('[data-slot="today-hero-fact"]');
const expected = digest.today.length;
await expect(facts).toHaveCount(expected);
const visible = await facts.evaluateAll(
(items) =>
items.filter((el) => (el as HTMLElement).offsetParent !== null)
.length,
);
expect(visible).toBe(
viewport.width < 768 ? Math.min(expected, 4) : expected,
);
// The all-clear sentence never sits under a lead or facts.
await expect(
hero.locator('[data-slot="today-hero-all-clear"]'),
).toHaveCount(0);
if (expected === 0) {
await expect(
hero.locator('[data-slot="today-hero-today"]'),
).toHaveCount(0);
// A quiet day keeps the full composition: lead beside the ring.
await expect(hero).toHaveAttribute("data-layout", "narrative");
return;
}
const block = hero.locator('[data-slot="today-hero-today"]');
const blockBox = (await block.boundingBox())!;
// The facts follow the lead.
expect(blockBox.y).toBeGreaterThanOrEqual(
leadBox.y + leadBox.height - 1,
);
if (viewport.width >= 768) {
// Beside the ring on a wide screen, never under it.
expect(blockBox.x + blockBox.width).toBeLessThanOrEqual(
ringBox.x + 1,
);
expect(blockBox.y).toBeLessThan(ringBox.y + ringBox.height);
} else {
// Above the ring on a phone, so the day reads before the dial.
expect(blockBox.y + blockBox.height).toBeLessThanOrEqual(
ringBox.y + 1,
);
}
// No fact line overflows its own box.
const clipped = await facts.evaluateAll(
(items) =>
items.filter((el) => {
const link = el.querySelector("a");
return link ? link.scrollWidth > link.clientWidth + 1 : false;
}).length,
);
expect(clipped).toBe(0);
});
}
}
test("the hero width does not move when the overview arrives", async ({
page,
}) => {
await page.setViewportSize({ width: 1440, height: 900 });
let calls = 0;
await mockDashboardSnapshot(page, { summaries: POPULATED_SUMMARIES });
await page.route(/\/api\/daily\/digest(\?|$)/, async (route) => {
calls += 1;
// First paint: a quiet digest. Every later read carries the overview.
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: calls > 1 ? STATES["ai-on"] : STATES.quiet,
error: null,
}),
});
});
await page.goto("/", { waitUntil: "domcontentloaded" });
const hero = page.locator('[data-slot="today-hero"]');
await expect(hero).toBeVisible({ timeout: 15_000 });
const before = (await hero.boundingBox())!;
// The tab becomes visible again: the signal TanStack Query's focus
// manager refetches on, as `today-just-in.spec.ts` uses.
await page.evaluate(() =>
window.dispatchEvent(new Event("visibilitychange")),
);
await expect(hero.locator('[data-slot="today-hero-today"]')).toBeVisible({
timeout: 15_000,
});
const after = (await hero.boundingBox())!;
expect(Math.abs(after.x - before.x)).toBeLessThanOrEqual(1);
expect(Math.abs(after.width - before.width)).toBeLessThanOrEqual(1);
});
});
+4
View File
@@ -31,6 +31,9 @@ const NARRATIVE_DIGEST: DailyDigest = {
},
topSignal: LONG_HEADLINE_BRIEFING.signalsOfDay?.[0] ?? null,
briefingLead: LONG_HEADLINE_BRIEFING.paragraph,
lead: { text: LONG_HEADLINE_BRIEFING.paragraph, source: "briefing" },
today: [],
restMode: null,
line: LONG_HEADLINE_BRIEFING.paragraph,
worthALook: [],
justIn: null,
@@ -104,6 +107,7 @@ test.describe("v1.34.1 deterministic UI evidence", () => {
...NARRATIVE_DIGEST,
topSignal: null,
briefingLead: null,
lead: null,
line: "Dein Gesundheitsscore liegt heute bei 84.",
reactionLine: null,
};
+138 -25
View File
@@ -2877,7 +2877,6 @@
"sourcesFresh": "Aktuell",
"sourcesStale": "Veraltet",
"sourcesFooter": "Der Coach liest nur Daten, die du verbunden hast.",
"evidenceLabel": "Worauf bezieht sich das?",
"metric": {
"bp": "Blutdruck",
"weight": "Gewicht",
@@ -2923,14 +2922,6 @@
"skin_temp": "Hauttemperatur",
"workouts": "Workouts"
},
"window": {
"last7days": "letzte 7 Tage",
"last30days": "letzte 30 Tage",
"last90days": "letzte 90 Tage",
"lastYear": "letzte 12 Monate",
"allTime": "gesamter Zeitraum (12 Monate im Detail)"
},
"windowLabel": "Zeitraum",
"settingsAriaLabel": "Coach-Einstellungen",
"settingsTitle": "Coach-Einstellungen",
"settingsDescription": "Stell ein, wie der Coach mit dir spricht.",
@@ -2943,8 +2934,9 @@
"settingsVerbosityDefault": "Standard",
"settingsVerbosityDetailed": "Ausführlich",
"settingsVerbosityConciseHint": "Der knappe Tonfall hält Antworten ohnehin kurz — die Ausführlichkeit ist dabei fest auf „Kurz“ gesetzt.",
"settingsSourcesPointer": "Welche Datenquellen der Coach nutzen darf und das Standard-Analysefenster verwaltest du direkt im Coach-Chat (Leiste „Worauf ich zugreife“).",
"settingsSourcesPointerLink": "Coach öffnen",
"settingsSourcesSummary": "Der Coach sieht {count} von {total} Datenbereichen und blickt {window} zurück.",
"settingsSourcesSummaryAll": "Der Coach sieht {count} von {total} Datenbereichen und deinen ganzen Verlauf.",
"settingsSourcesPointerLink": "Coach-Einstellungen öffnen",
"settingsExcludeLabel": "Metriken ausschließen",
"settingsExcludeHint": "Der Coach sieht keine Daten der ausgewählten Metriken.",
"settingsContextLabel": "Kontext mitschicken",
@@ -2956,10 +2948,6 @@
"settingsSave": "Speichern",
"settingsSaved": "Gespeichert",
"settingsCancel": "Abbrechen",
"feedbackHelpful": "Hilfreich",
"feedbackUnhelpful": "Nicht ganz",
"feedbackThanks": "Danke für das Signal.",
"feedbackError": "Feedback konnte nicht gespeichert werden",
"dailyLimitTitle": "Heute-Limit erreicht",
"dailyLimitBody": "Tageslimit erreicht. Das Budget setzt sich um Mitternacht (UTC) zurück.",
"providerRateLimitTitle": "Provider rate-limited",
@@ -3009,7 +2997,23 @@
"settingsClustersLabel": "Daten, die der Coach sieht",
"settingsClustersHint": "Wähle, welche Datencluster der Coach lesen darf. Mehr Daten bedeuten reichhaltigere Antworten und einen größeren Prompt.",
"settingsClusterNoData": "Noch keine Daten",
"sourcesMemberCount": "{count} Werte",
"sourcesMemberCountOne": "1 Wert",
"sourcesMemberCountFew": "{count} Werte",
"sourcesMemberCountOther": "{count} Werte",
"lookback": {
"label": "Wie weit der Coach zurückblickt",
"option": {
"last7days": "{days} Tage",
"last30days": "{days} Tage",
"last90days": "{days} Tage",
"lastYear": "{months} Monate",
"allTime": "Alles"
},
"detailDays": "Der Coach liest nichts, was älter als {days} Tage ist, in keiner Antwort.",
"detailMonths": "Der Coach liest nichts, was älter als {months} Monate ist, in keiner Antwort.",
"detailAll": "Alles, was du erfasst hast: {dailyMonths} Monate tagesgenau, ältere Werte als Monatswerte bis {years} Jahre zurück, Laborwerte der letzten {labMonths} Monate.",
"note": "Dein Profil, aktuelle Medikamente, eine laufende Erkrankung und anstehende Termine bleiben sichtbar; Auswertungen, die mehr Verlauf brauchen, werden gekürzt oder weggelassen. Antworten, die der Coach früher in älteren Unterhaltungen gegeben hat, können noch ältere Werte nennen."
},
"showConversations": "Unterhaltungen anzeigen",
"hideConversations": "Unterhaltungen ausblenden",
"heroGreeting": "Frage mich etwas zu deinen Daten",
@@ -3113,7 +3117,34 @@
"errorNotPermitted": "Der Coach ist in einer Akte, die jemand mit dir teilt, nicht verfügbar.",
"errorUnavailable": "Der Coach konnte seine Einstellungen gerade nicht prüfen. Bitte versuch es gleich noch einmal.",
"errorOperatorOff": "KI ist auf diesem Server abgeschaltet, deshalb kann der Coach nicht antworten.",
"errorCoachOff": "Der Coach ist ausgeschaltet. Du kannst ihn unter Einstellungen, Module wieder einschalten."
"errorCoachOff": "Der Coach ist ausgeschaltet. Du kannst ihn unter Einstellungen, Module wieder einschalten.",
"frame": {
"showPanel": "Unterhaltungen einblenden",
"hidePanel": "Unterhaltungen ausblenden",
"panelDescription": "Deine Unterhaltungen mit dem Coach, neueste zuerst.",
"rowActions": "Aktionen für {title}",
"rename": "Umbenennen",
"delete": "Löschen",
"modelSection": "Modell",
"provider": "Antwortet mit",
"modelManaged": "Das Modell für diesen Anbieter legst du in den KI-Einstellungen fest.",
"noProvider": "Kein Anbieter verfügbar",
"saving": "Wird gespeichert…",
"saveFailed": "Die Änderung ließ sich nicht speichern.",
"allSettings": "Alle KI-Einstellungen",
"providerNotActive": "{provider} konnte nicht übernehmen: Es fehlt eine funktionierende Anmeldung oder ein Schlüssel. Richte das in den KI-Einstellungen ein.",
"operatorCodex": "ChatGPT (vom Betreiber bereitgestellt)",
"operatorManaged": "Diese Verbindung verwaltet der Betreiber deines Servers. Sobald einer deiner eigenen Anbieter eingerichtet ist, antwortet er zuerst."
},
"answer": {
"scrollToBottom": "Zur neuesten Nachricht",
"rememberDone": "Gemerkt",
"timeYesterday": "gestern {time}",
"timeEarlier": "{date}, {time}",
"areasDoneOne": "1 Bereich angesehen",
"areasDoneFew": "{count} Bereiche angesehen",
"areasDoneOther": "{count} Bereiche angesehen"
}
},
"relativeJustNow": "gerade eben",
"relativeMinutesAgoOne": "vor {count} Minute",
@@ -5090,11 +5121,6 @@
"brief": "Kurz",
"default": "Standard",
"detailed": "Ausführlich",
"defaultWindow": "Standardzeitraum",
"last7days": "Letzte 7 Tage",
"last30days": "Letzte 30 Tage",
"last90days": "Letzte 90 Tage",
"allTime": "Gesamter Zeitraum",
"excludedMetrics": "Ausgeschlossene Messwerte",
"dataClusters": "Datenbereiche",
"customClusters": "Eigene Auswahl von Datenbereichen verwenden",
@@ -10431,11 +10457,13 @@
"searchPlaceholder": "Titel oder Dateiname suchen…",
"typeGroup": "Dokumenttyp",
"conditionGroup": "Erkrankung",
"procedureGroup": "Eingriffe und Operationen",
"yearGroup": "Jahr",
"clear": "Filter zurücksetzen",
"typeAll": "Alle Typen",
"typeCount": "{count} Typen",
"conditionAll": "Alle Erkrankungen",
"procedureAll": "Alle Eingriffe",
"yearAll": "Alle Jahre"
},
"card": {
@@ -10896,8 +10924,8 @@
},
"daily": {
"line": {
"score": "Dein Gesundheitsscore heute liegt bei {score}.",
"allClear": "Heute braucht nichts deine Aufmerksamkeit."
"allClear": "Heute braucht nichts deine Aufmerksamkeit.",
"scoreReady": "Dein Gesundheitsscore für heute ist da."
},
"push": {
"title": "Dein Morgenüberblick",
@@ -10983,7 +11011,30 @@
"sleepPending": "Der Schlaf von letzter Nacht fehlt noch — der heutige Wert ist vorläufig.",
"worthALook": "Einen Blick wert",
"allClear": "Heute braucht nichts deine Aufmerksamkeit.",
"ringLink": "{metric}-Details öffnen"
"ringLink": "{metric}-Details öffnen",
"heading": "Heute",
"steadyWeeksOne": "Seit {count} Woche stabil",
"steadyWeeksFew": "Seit {count} Wochen stabil",
"steadyWeeksOther": "Seit {count} Wochen stabil",
"valueWithUnit": "{value} {unit}",
"rangeWithUnit": "{low} bis {high} {unit}",
"vital": {
"RESTING_HEART_RATE": "Ruhepuls",
"HEART_RATE_VARIABILITY": "Herzfrequenzvariabilität",
"RESPIRATORY_RATE": "Atemfrequenz",
"OXYGEN_SATURATION": "Sauerstoffsättigung",
"BODY_TEMPERATURE": "Körpertemperatur",
"SKIN_TEMPERATURE": "Hauttemperatur",
"BLOOD_PRESSURE_SYS": "Systolischer Blutdruck",
"BLOOD_PRESSURE_DIA": "Diastolischer Blutdruck",
"BLOOD_GLUCOSE": "Blutzucker",
"PULSE": "Puls",
"WEIGHT": "Gewicht",
"generic": "Ein Vitalwert"
},
"steadyAtLeastWeeksOne": "Seit mindestens {count} Woche stabil",
"steadyAtLeastWeeksFew": "Seit mindestens {count} Wochen stabil",
"steadyAtLeastWeeksOther": "Seit mindestens {count} Wochen stabil"
},
"milestone": {
"metric": {
@@ -11019,6 +11070,68 @@
"body": "Ein ganzer Monat mit {metric} in deinem gewohnten Bereich. Wunderbar gleichmäßig."
}
}
},
"lead": {
"vitalOutside": {
"above": "{metric} liegt bei {value}, über deinem üblichen Bereich von {range}.",
"below": "{metric} liegt bei {value}, unter deinem üblichen Bereich von {range}."
},
"vitalsOutside": {
"aboveOne": "{metric} liegt bei {value}, über deinem üblichen Bereich von {range}, und {count} weiterer Vitalwert liegt ebenfalls außerhalb.",
"aboveFew": "{metric} liegt bei {value}, über deinem üblichen Bereich von {range}, und {count} weitere Vitalwerte liegen ebenfalls außerhalb.",
"aboveOther": "{metric} liegt bei {value}, über deinem üblichen Bereich von {range}, und {count} weitere Vitalwerte liegen ebenfalls außerhalb.",
"belowOne": "{metric} liegt bei {value}, unter deinem üblichen Bereich von {range}, und {count} weiterer Vitalwert liegt ebenfalls außerhalb.",
"belowFew": "{metric} liegt bei {value}, unter deinem üblichen Bereich von {range}, und {count} weitere Vitalwerte liegen ebenfalls außerhalb.",
"belowOther": "{metric} liegt bei {value}, unter deinem üblichen Bereich von {range}, und {count} weitere Vitalwerte liegen ebenfalls außerhalb."
},
"sleep": {
"more": "Letzte Nacht hast du {duration} geschlafen, {diff} mehr als sonst.",
"less": "Letzte Nacht hast du {duration} geschlafen, {diff} weniger als sonst.",
"usual": "Letzte Nacht hast du {duration} geschlafen, etwa so viel wie sonst.",
"plain": "Letzte Nacht hast du {duration} geschlafen."
},
"vitalsInRangeOne": "Dein letzter Vitalwert liegt in seinem üblichen Bereich.",
"vitalsInRangeFew": "Alle {count} letzten Vitalwerte liegen in ihrem üblichen Bereich.",
"vitalsInRangeOther": "Alle {count} letzten Vitalwerte liegen in ihrem üblichen Bereich.",
"vitalInRange": "{metric} liegt bei {value}, innerhalb deines üblichen Bereichs von {range}."
},
"todayFact": {
"restMode": {
"label": "Ruhemodus",
"value": "Tag {day}"
},
"medications": {
"label": "Medikamente",
"value": "{taken} von {scheduled} genommen"
},
"appointment": {
"label": "Termin",
"today": "Heute {time}, {what}",
"tomorrow": "Morgen {time}, {what}"
},
"sleep": {
"label": "Letzte Nacht",
"plain": "{duration}",
"usual": "{duration}, etwa wie sonst",
"more": "{duration}, {diff} mehr als sonst",
"less": "{duration}, {diff} weniger als sonst"
},
"vitals": {
"label": "Vitalwerte",
"allInOne": "{count} geprüft, im Bereich",
"allInFew": "{count} geprüft, alle im Bereich",
"allInOther": "{count} geprüft, alle im Bereich",
"oneOut": {
"above": "{metric} über deinem Bereich",
"below": "{metric} unter deinem Bereich"
},
"someOut": "{count} von {total} außerhalb deines Bereichs"
},
"cycle": {
"label": "Zyklus",
"value": "{phase}, Tag {day}",
"dayOnly": "Tag {day}"
}
}
},
"reportSelection": {
+138 -25
View File
@@ -2877,7 +2877,6 @@
"sourcesFresh": "Fresh",
"sourcesStale": "Stale",
"sourcesFooter": "The Coach reads only the data you've connected.",
"evidenceLabel": "What I'm looking at",
"metric": {
"bp": "Blood pressure",
"weight": "Weight",
@@ -2923,14 +2922,6 @@
"skin_temp": "Skin temperature",
"workouts": "Workouts"
},
"window": {
"last7days": "last 7 days",
"last30days": "last 30 days",
"last90days": "last 90 days",
"lastYear": "last 12 months",
"allTime": "all time (12 months of detail)"
},
"windowLabel": "Window",
"settingsAriaLabel": "Coach settings",
"settingsTitle": "Coach settings",
"settingsDescription": "Tune how the Coach talks with you.",
@@ -2943,8 +2934,9 @@
"settingsVerbosityDefault": "Default",
"settingsVerbosityDetailed": "Detailed",
"settingsVerbosityConciseHint": "The concise tone already keeps replies short — verbosity is pinned to Brief while it is active.",
"settingsSourcesPointer": "Which data clusters the coach may draw on and the default analysis window are managed directly in the coach chat (\"What I draw on\" rail).",
"settingsSourcesPointerLink": "Open the coach",
"settingsSourcesSummary": "The Coach sees {count} of {total} data areas and looks back {window}.",
"settingsSourcesSummaryAll": "The Coach sees {count} of {total} data areas and your whole history.",
"settingsSourcesPointerLink": "Open Coach settings",
"settingsExcludeLabel": "Exclude metrics",
"settingsExcludeHint": "The Coach will not see data for selected metrics.",
"settingsContextLabel": "Include context",
@@ -2956,10 +2948,6 @@
"settingsSave": "Save",
"settingsSaved": "Saved",
"settingsCancel": "Cancel",
"feedbackHelpful": "Helpful",
"feedbackUnhelpful": "Not quite",
"feedbackThanks": "Thanks for the signal.",
"feedbackError": "Could not save feedback",
"dailyLimitTitle": "Daily limit reached",
"dailyLimitBody": "Daily limit reached. The budget refreshes at midnight UTC.",
"providerRateLimitTitle": "Provider rate-limited",
@@ -3009,7 +2997,23 @@
"settingsClustersLabel": "Data the Coach sees",
"settingsClustersHint": "Pick which clusters of your data the Coach can read. More data means richer answers and a larger prompt.",
"settingsClusterNoData": "No data yet",
"sourcesMemberCount": "{count} metrics",
"sourcesMemberCountOne": "1 metric",
"sourcesMemberCountFew": "{count} metrics",
"sourcesMemberCountOther": "{count} metrics",
"lookback": {
"label": "How far back the Coach can look",
"option": {
"last7days": "{days} days",
"last30days": "{days} days",
"last90days": "{days} days",
"lastYear": "{months} months",
"allTime": "All"
},
"detailDays": "The Coach reads nothing older than {days} days, in any answer.",
"detailMonths": "The Coach reads nothing older than {months} months, in any answer.",
"detailAll": "Everything you recorded: {dailyMonths} months in daily detail, older readings as monthly values up to {years} years back, lab results from the last {labMonths} months.",
"note": "Your profile, current medications, an ongoing illness and upcoming visits stay visible; summaries that need more history are shortened or left out. Answers the Coach gave earlier in older conversations may still quote older values."
},
"showConversations": "Show conversations",
"hideConversations": "Hide conversations",
"heroGreeting": "Ask me anything about your data",
@@ -3113,7 +3117,34 @@
"errorNotPermitted": "The Coach is not available inside a record someone shared with you.",
"errorUnavailable": "The Coach could not check its settings just now. Please try again in a moment.",
"errorOperatorOff": "AI is switched off on this server, so the Coach cannot answer.",
"errorCoachOff": "The Coach is switched off. You can turn it back on in Settings, Modules."
"errorCoachOff": "The Coach is switched off. You can turn it back on in Settings, Modules.",
"frame": {
"showPanel": "Show conversations",
"hidePanel": "Hide conversations",
"panelDescription": "Your conversations with the Coach, newest first.",
"rowActions": "Actions for {title}",
"rename": "Rename",
"delete": "Delete",
"modelSection": "Model",
"provider": "Answers with",
"modelManaged": "The model for this provider is set in AI settings.",
"noProvider": "No provider available",
"saving": "Saving…",
"saveFailed": "That change could not be saved.",
"allSettings": "All AI settings",
"providerNotActive": "{provider} could not take over: it has no working sign-in or key. Set it up in AI settings.",
"operatorCodex": "ChatGPT (shared by the operator)",
"operatorManaged": "Your server's operator manages this connection. Once one of your own providers is set up, it answers first."
},
"answer": {
"scrollToBottom": "Jump to the latest message",
"rememberDone": "Remembered",
"timeYesterday": "yesterday {time}",
"timeEarlier": "{date}, {time}",
"areasDoneOne": "Looked at 1 area",
"areasDoneFew": "Looked at {count} areas",
"areasDoneOther": "Looked at {count} areas"
}
},
"relativeJustNow": "just now",
"relativeMinutesAgoOne": "{count} minute ago",
@@ -5090,11 +5121,6 @@
"brief": "Brief",
"default": "Default",
"detailed": "Detailed",
"defaultWindow": "Default window",
"last7days": "Last 7 days",
"last30days": "Last 30 days",
"last90days": "Last 90 days",
"allTime": "All time",
"excludedMetrics": "Excluded metrics",
"dataClusters": "Data clusters",
"customClusters": "Use a custom cluster selection",
@@ -10431,11 +10457,13 @@
"searchPlaceholder": "Search title or filename…",
"typeGroup": "Document type",
"conditionGroup": "Condition",
"procedureGroup": "Procedures and surgeries",
"yearGroup": "Year",
"clear": "Clear filters",
"typeAll": "All types",
"typeCount": "{count} types",
"conditionAll": "All conditions",
"procedureAll": "All procedures",
"yearAll": "All years"
},
"card": {
@@ -10896,8 +10924,8 @@
},
"daily": {
"line": {
"score": "Your health score today is {score}.",
"allClear": "Nothing needs your attention today."
"allClear": "Nothing needs your attention today.",
"scoreReady": "Your health score for today is ready."
},
"push": {
"title": "Your morning briefing",
@@ -10983,7 +11011,30 @@
"sleepPending": "Last night's sleep isn't in yet — today's read is provisional.",
"worthALook": "Worth a look",
"allClear": "Nothing needs your attention today.",
"ringLink": "Open {metric} details"
"ringLink": "Open {metric} details",
"heading": "Today",
"steadyWeeksOne": "Steady for {count} week",
"steadyWeeksFew": "Steady for {count} weeks",
"steadyWeeksOther": "Steady for {count} weeks",
"valueWithUnit": "{value} {unit}",
"rangeWithUnit": "{low} to {high} {unit}",
"vital": {
"RESTING_HEART_RATE": "Resting heart rate",
"HEART_RATE_VARIABILITY": "Heart-rate variability",
"RESPIRATORY_RATE": "Respiratory rate",
"OXYGEN_SATURATION": "Oxygen saturation",
"BODY_TEMPERATURE": "Body temperature",
"SKIN_TEMPERATURE": "Skin temperature",
"BLOOD_PRESSURE_SYS": "Systolic blood pressure",
"BLOOD_PRESSURE_DIA": "Diastolic blood pressure",
"BLOOD_GLUCOSE": "Blood glucose",
"PULSE": "Pulse",
"WEIGHT": "Weight",
"generic": "A vital"
},
"steadyAtLeastWeeksOne": "Steady for at least {count} week",
"steadyAtLeastWeeksFew": "Steady for at least {count} weeks",
"steadyAtLeastWeeksOther": "Steady for at least {count} weeks"
},
"milestone": {
"metric": {
@@ -11019,6 +11070,68 @@
"body": "A full month with your {metric} in your usual range. Beautifully steady."
}
}
},
"lead": {
"vitalOutside": {
"above": "{metric} is at {value}, above your usual range of {range}.",
"below": "{metric} is at {value}, below your usual range of {range}."
},
"vitalsOutside": {
"aboveOne": "{metric} is at {value}, above your usual range of {range}, and {count} more vital is outside its range too.",
"aboveFew": "{metric} is at {value}, above your usual range of {range}, and {count} more vitals are outside their range too.",
"aboveOther": "{metric} is at {value}, above your usual range of {range}, and {count} more vitals are outside their range too.",
"belowOne": "{metric} is at {value}, below your usual range of {range}, and {count} more vital is outside its range too.",
"belowFew": "{metric} is at {value}, below your usual range of {range}, and {count} more vitals are outside their range too.",
"belowOther": "{metric} is at {value}, below your usual range of {range}, and {count} more vitals are outside their range too."
},
"sleep": {
"more": "Last night you slept {duration}, {diff} more than usual.",
"less": "Last night you slept {duration}, {diff} less than usual.",
"usual": "Last night you slept {duration}, close to your usual.",
"plain": "Last night you slept {duration}."
},
"vitalsInRangeOne": "Your latest vital sits inside its usual range.",
"vitalsInRangeFew": "All {count} of your latest vitals sit inside their usual range.",
"vitalsInRangeOther": "All {count} of your latest vitals sit inside their usual range.",
"vitalInRange": "{metric} is at {value}, inside your usual range of {range}."
},
"todayFact": {
"restMode": {
"label": "Rest mode",
"value": "Day {day}"
},
"medications": {
"label": "Medications",
"value": "{taken} of {scheduled} taken"
},
"appointment": {
"label": "Appointment",
"today": "Today {time}, {what}",
"tomorrow": "Tomorrow {time}, {what}"
},
"sleep": {
"label": "Last night",
"plain": "{duration}",
"usual": "{duration}, close to your usual",
"more": "{duration}, {diff} more than usual",
"less": "{duration}, {diff} less than usual"
},
"vitals": {
"label": "Vitals",
"allInOne": "{count} checked, in your range",
"allInFew": "{count} checked, all in your range",
"allInOther": "{count} checked, all in your range",
"oneOut": {
"above": "{metric} above your range",
"below": "{metric} below your range"
},
"someOut": "{count} of {total} outside your range"
},
"cycle": {
"label": "Cycle",
"value": "{phase}, day {day}",
"dayOnly": "Day {day}"
}
}
},
"reportSelection": {
+138 -25
View File
@@ -2877,7 +2877,6 @@
"sourcesFresh": "Actuales",
"sourcesStale": "Desactualizados",
"sourcesFooter": "El coach solo lee los datos que has conectado.",
"evidenceLabel": "¿En qué se basa esto?",
"metric": {
"bp": "Presión arterial",
"weight": "Peso",
@@ -2923,14 +2922,6 @@
"skin_temp": "Temperatura de la piel",
"workouts": "Entrenamientos"
},
"window": {
"last7days": "Últimos 7 días",
"last30days": "Últimos 30 días",
"last90days": "Últimos 90 días",
"lastYear": "últimos 12 meses",
"allTime": "todo el tiempo (12 meses en detalle)"
},
"windowLabel": "Periodo",
"settingsAriaLabel": "Ajustes del coach",
"settingsTitle": "Ajustes del coach",
"settingsDescription": "Configura cómo te habla el coach.",
@@ -2943,8 +2934,9 @@
"settingsVerbosityDefault": "Estándar",
"settingsVerbosityDetailed": "Detallado",
"settingsVerbosityConciseHint": "El tono conciso ya mantiene las respuestas cortas: la extensión queda fijada en «Breve» mientras esté activo.",
"settingsSourcesPointer": "Los grupos de datos que el coach puede usar y la ventana de análisis predeterminada se gestionan directamente en el chat del coach (panel «En qué me baso»).",
"settingsSourcesPointerLink": "Abrir el coach",
"settingsSourcesSummary": "El coach ve {count} de {total} áreas de datos y mira {window} atrás.",
"settingsSourcesSummaryAll": "El coach ve {count} de {total} áreas de datos y todo el historial.",
"settingsSourcesPointerLink": "Abrir ajustes del coach",
"settingsExcludeLabel": "Excluir métricas",
"settingsExcludeHint": "El coach no verá los datos de las métricas seleccionadas.",
"settingsContextLabel": "Enviar contexto",
@@ -2956,10 +2948,6 @@
"settingsSave": "Guardar",
"settingsSaved": "Guardado",
"settingsCancel": "Cancelar",
"feedbackHelpful": "Útil",
"feedbackUnhelpful": "Poco útil",
"feedbackThanks": "Gracias por la indicación.",
"feedbackError": "No se pudo guardar el comentario",
"dailyLimitTitle": "Límite diario alcanzado",
"dailyLimitBody": "Límite diario alcanzado. El cupo se reinicia a medianoche (UTC).",
"providerRateLimitTitle": "Proveedor con límite de tasa",
@@ -3009,7 +2997,23 @@
"settingsClustersLabel": "Datos que ve el Coach",
"settingsClustersHint": "Elige qué grupos de datos puede leer el Coach. Más datos significan respuestas más ricas y un prompt mayor.",
"settingsClusterNoData": "Aún no hay datos",
"sourcesMemberCount": "{count} métricas",
"sourcesMemberCountOne": "1 métrica",
"sourcesMemberCountFew": "{count} métricas",
"sourcesMemberCountOther": "{count} métricas",
"lookback": {
"label": "Hasta dónde puede mirar atrás el Coach",
"option": {
"last7days": "{days} días",
"last30days": "{days} días",
"last90days": "{days} días",
"lastYear": "{months} meses",
"allTime": "Todo"
},
"detailDays": "El Coach no lee nada con más de {days} días de antigüedad, en ninguna respuesta.",
"detailMonths": "El Coach no lee nada con más de {months} meses de antigüedad, en ninguna respuesta.",
"detailAll": "Todo lo que has registrado: {dailyMonths} meses al detalle diario, las mediciones más antiguas como valores mensuales hasta {years} años atrás, los análisis de los últimos {labMonths} meses.",
"note": "Tu perfil, tu medicación actual, una enfermedad en curso y las próximas citas siguen visibles; los resúmenes que necesitan más historial se acortan o se omiten. Las respuestas que el Coach dio antes en conversaciones anteriores pueden seguir citando valores más antiguos."
},
"showConversations": "Mostrar conversaciones",
"hideConversations": "Ocultar conversaciones",
"heroGreeting": "Pregúntame lo que quieras sobre tus datos",
@@ -3113,7 +3117,34 @@
"errorNotPermitted": "El Coach no está disponible dentro de un registro que alguien comparte contigo.",
"errorUnavailable": "El Coach no pudo comprobar su configuración ahora. Inténtalo de nuevo en un momento.",
"errorOperatorOff": "La IA está desactivada en este servidor, así que el Coach no puede responder.",
"errorCoachOff": "El Coach está desactivado. Puedes volver a activarlo en Ajustes, Módulos."
"errorCoachOff": "El Coach está desactivado. Puedes volver a activarlo en Ajustes, Módulos.",
"frame": {
"showPanel": "Mostrar conversaciones",
"hidePanel": "Ocultar conversaciones",
"panelDescription": "Tus conversaciones con el coach, las más recientes primero.",
"rowActions": "Acciones para {title}",
"rename": "Renombrar",
"delete": "Eliminar",
"modelSection": "Modelo",
"provider": "Responde con",
"modelManaged": "El modelo de este proveedor se configura en los ajustes de IA.",
"noProvider": "Ningún proveedor disponible",
"saving": "Guardando…",
"saveFailed": "No se pudo guardar el cambio.",
"allSettings": "Todos los ajustes de IA",
"providerNotActive": "{provider} no pudo encargarse: no tiene un inicio de sesión ni una clave que funcionen. Configúralo en los ajustes de IA.",
"operatorCodex": "ChatGPT (compartido por el operador)",
"operatorManaged": "El operador de tu servidor gestiona esta conexión. Cuando configures uno de tus propios proveedores, responderá primero."
},
"answer": {
"scrollToBottom": "Ir al último mensaje",
"rememberDone": "Guardado",
"timeYesterday": "ayer {time}",
"timeEarlier": "{date}, {time}",
"areasDoneOne": "1 área consultada",
"areasDoneFew": "{count} áreas consultadas",
"areasDoneOther": "{count} áreas consultadas"
}
},
"relativeJustNow": "ahora mismo",
"relativeMinutesAgoOne": "hace {count} minuto",
@@ -5090,11 +5121,6 @@
"brief": "Breve",
"default": "Predeterminado",
"detailed": "Detallado",
"defaultWindow": "Periodo predeterminado",
"last7days": "Últimos 7 días",
"last30days": "Últimos 30 días",
"last90days": "Últimos 90 días",
"allTime": "Todo el tiempo",
"excludedMetrics": "Métricas excluidas",
"dataClusters": "Grupos de datos",
"customClusters": "Usar una selección personalizada de grupos",
@@ -10431,11 +10457,13 @@
"searchPlaceholder": "Buscar por título o nombre de archivo…",
"typeGroup": "Tipo de documento",
"conditionGroup": "Afección",
"procedureGroup": "Procedimientos y cirugías",
"yearGroup": "Año",
"clear": "Borrar filtros",
"typeAll": "Todos los tipos",
"typeCount": "{count} tipos",
"conditionAll": "Todas las afecciones",
"procedureAll": "Todos los procedimientos",
"yearAll": "Todos los años"
},
"card": {
@@ -10896,8 +10924,8 @@
},
"daily": {
"line": {
"score": "Tu puntuación de salud hoy es {score}.",
"allClear": "Hoy nada necesita tu atención."
"allClear": "Hoy nada necesita tu atención.",
"scoreReady": "Tu puntuación de salud de hoy está lista."
},
"push": {
"title": "Tu resumen matinal",
@@ -10983,7 +11011,30 @@
"sleepPending": "El sueño de anoche aún no ha llegado; la lectura de hoy es provisional.",
"worthALook": "Merece un vistazo",
"allClear": "Hoy nada necesita tu atención.",
"ringLink": "Abrir detalles de {metric}"
"ringLink": "Abrir detalles de {metric}",
"heading": "Hoy",
"steadyWeeksOne": "Estable desde hace {count} semana",
"steadyWeeksFew": "Estable desde hace {count} semanas",
"steadyWeeksOther": "Estable desde hace {count} semanas",
"valueWithUnit": "{value} {unit}",
"rangeWithUnit": "{low} a {high} {unit}",
"vital": {
"RESTING_HEART_RATE": "Frecuencia cardíaca en reposo",
"HEART_RATE_VARIABILITY": "Variabilidad de la frecuencia cardíaca",
"RESPIRATORY_RATE": "Frecuencia respiratoria",
"OXYGEN_SATURATION": "Saturación de oxígeno",
"BODY_TEMPERATURE": "Temperatura corporal",
"SKIN_TEMPERATURE": "Temperatura de la piel",
"BLOOD_PRESSURE_SYS": "Presión arterial sistólica",
"BLOOD_PRESSURE_DIA": "Presión arterial diastólica",
"BLOOD_GLUCOSE": "Glucosa en sangre",
"PULSE": "Pulso",
"WEIGHT": "Peso",
"generic": "Un signo vital"
},
"steadyAtLeastWeeksOne": "Estable desde hace al menos {count} semana",
"steadyAtLeastWeeksFew": "Estable desde hace al menos {count} semanas",
"steadyAtLeastWeeksOther": "Estable desde hace al menos {count} semanas"
},
"milestone": {
"metric": {
@@ -11019,6 +11070,68 @@
"body": "Un mes entero con tu {metric} dentro de tu rango habitual. Maravillosamente estable."
}
}
},
"lead": {
"vitalOutside": {
"above": "{metric} está en {value}, por encima de tu rango habitual de {range}.",
"below": "{metric} está en {value}, por debajo de tu rango habitual de {range}."
},
"vitalsOutside": {
"aboveOne": "{metric} está en {value}, por encima de tu rango habitual de {range}, y {count} signo vital más también está fuera de su rango.",
"aboveFew": "{metric} está en {value}, por encima de tu rango habitual de {range}, y {count} signos vitales más también están fuera de su rango.",
"aboveOther": "{metric} está en {value}, por encima de tu rango habitual de {range}, y {count} signos vitales más también están fuera de su rango.",
"belowOne": "{metric} está en {value}, por debajo de tu rango habitual de {range}, y {count} signo vital más también está fuera de su rango.",
"belowFew": "{metric} está en {value}, por debajo de tu rango habitual de {range}, y {count} signos vitales más también están fuera de su rango.",
"belowOther": "{metric} está en {value}, por debajo de tu rango habitual de {range}, y {count} signos vitales más también están fuera de su rango."
},
"sleep": {
"more": "Anoche dormiste {duration}, {diff} más de lo habitual.",
"less": "Anoche dormiste {duration}, {diff} menos de lo habitual.",
"usual": "Anoche dormiste {duration}, cerca de lo habitual.",
"plain": "Anoche dormiste {duration}."
},
"vitalsInRangeOne": "Tu último signo vital está dentro de su rango habitual.",
"vitalsInRangeFew": "Tus {count} últimos signos vitales están dentro de su rango habitual.",
"vitalsInRangeOther": "Tus {count} últimos signos vitales están dentro de su rango habitual.",
"vitalInRange": "{metric} está en {value}, dentro de tu rango habitual de {range}."
},
"todayFact": {
"restMode": {
"label": "Modo descanso",
"value": "Día {day}"
},
"medications": {
"label": "Medicamentos",
"value": "{taken} de {scheduled} tomados"
},
"appointment": {
"label": "Cita",
"today": "Hoy {time}, {what}",
"tomorrow": "Mañana {time}, {what}"
},
"sleep": {
"label": "Anoche",
"plain": "{duration}",
"usual": "{duration}, cerca de lo habitual",
"more": "{duration}, {diff} más de lo habitual",
"less": "{duration}, {diff} menos de lo habitual"
},
"vitals": {
"label": "Signos vitales",
"allInOne": "{count} revisado, en tu rango",
"allInFew": "{count} revisados, todos en tu rango",
"allInOther": "{count} revisados, todos en tu rango",
"oneOut": {
"above": "{metric} por encima de tu rango",
"below": "{metric} por debajo de tu rango"
},
"someOut": "{count} de {total} fuera de tu rango"
},
"cycle": {
"label": "Ciclo",
"value": "{phase}, día {day}",
"dayOnly": "Día {day}"
}
}
},
"reportSelection": {
+138 -25
View File
@@ -2877,7 +2877,6 @@
"sourcesFresh": "À jour",
"sourcesStale": "Obsolètes",
"sourcesFooter": "Le coach ne lit que les données que tu as connectées.",
"evidenceLabel": "Sur quoi est-ce fondé ?",
"metric": {
"bp": "Tension artérielle",
"weight": "Poids",
@@ -2923,14 +2922,6 @@
"skin_temp": "Température cutanée",
"workouts": "Entraînements"
},
"window": {
"last7days": "7 derniers jours",
"last30days": "30 derniers jours",
"last90days": "90 derniers jours",
"lastYear": "12 derniers mois",
"allTime": "toute la période (12 mois en détail)"
},
"windowLabel": "Période",
"settingsAriaLabel": "Paramètres du coach",
"settingsTitle": "Paramètres du coach",
"settingsDescription": "Configure comment le coach te parle.",
@@ -2943,8 +2934,9 @@
"settingsVerbosityDefault": "Standard",
"settingsVerbosityDetailed": "Détaillé",
"settingsVerbosityConciseHint": "Le ton concis garde déjà les réponses courtes — la longueur reste fixée sur « Bref » tant qu'il est actif.",
"settingsSourcesPointer": "Les groupes de données que le coach peut utiliser et la fenêtre d'analyse par défaut se gèrent directement dans le chat du coach (volet « Ce sur quoi je m'appuie »).",
"settingsSourcesPointerLink": "Ouvrir le coach",
"settingsSourcesSummary": "Le coach voit {count} domaines de données sur {total} et remonte sur {window}.",
"settingsSourcesSummaryAll": "Le coach voit {count} domaines de données sur {total} et tout l’historique.",
"settingsSourcesPointerLink": "Ouvrir les paramètres du coach",
"settingsExcludeLabel": "Exclure des métriques",
"settingsExcludeHint": "Le coach ne verra pas les données des métriques sélectionnées.",
"settingsContextLabel": "Envoyer le contexte",
@@ -2956,10 +2948,6 @@
"settingsSave": "Enregistrer",
"settingsSaved": "Enregistré",
"settingsCancel": "Annuler",
"feedbackHelpful": "Utile",
"feedbackUnhelpful": "Peu utile",
"feedbackThanks": "Merci pour l’indication.",
"feedbackError": "Impossible d'enregistrer le retour",
"dailyLimitTitle": "Limite quotidienne atteinte",
"dailyLimitBody": "Limite quotidienne atteinte. Le quota se réinitialise à minuit (UTC).",
"providerRateLimitTitle": "Fournisseur limité en débit",
@@ -3009,7 +2997,23 @@
"settingsClustersLabel": "Données vues par le Coach",
"settingsClustersHint": "Choisissez les groupes de données que le Coach peut lire. Plus de données, des réponses plus riches et un prompt plus grand.",
"settingsClusterNoData": "Pas encore de données",
"sourcesMemberCount": "{count} mesures",
"sourcesMemberCountOne": "1 mesure",
"sourcesMemberCountFew": "{count} mesures",
"sourcesMemberCountOther": "{count} mesures",
"lookback": {
"label": "Jusqu’où le Coach peut remonter",
"option": {
"last7days": "{days} jours",
"last30days": "{days} jours",
"last90days": "{days} jours",
"lastYear": "{months} mois",
"allTime": "Tout"
},
"detailDays": "Le Coach ne lit rien de plus ancien que {days} jours, dans aucune réponse.",
"detailMonths": "Le Coach ne lit rien de plus ancien que {months} mois, dans aucune réponse.",
"detailAll": "Tout ce que vous avez enregistré : {dailyMonths} mois au jour près, les mesures plus anciennes en valeurs mensuelles jusqu’à {years} ans en arrière, les analyses des {labMonths} derniers mois.",
"note": "Votre profil, vos médicaments actuels, une maladie en cours et vos prochains rendez-vous restent visibles ; les synthèses qui demandent plus d’historique sont raccourcies ou omises. Les réponses données plus tôt par le Coach dans d’anciennes conversations peuvent encore citer des valeurs plus anciennes."
},
"showConversations": "Afficher les conversations",
"hideConversations": "Masquer les conversations",
"heroGreeting": "Pose-moi une question sur tes données",
@@ -3113,7 +3117,34 @@
"errorNotPermitted": "Le Coach n'est pas disponible dans un dossier que quelqu'un partage avec vous.",
"errorUnavailable": "Le Coach n'a pas pu vérifier ses réglages pour l'instant. Réessayez dans un instant.",
"errorOperatorOff": "L'IA est désactivée sur ce serveur, le Coach ne peut donc pas répondre.",
"errorCoachOff": "Le Coach est désactivé. Vous pouvez le réactiver dans Réglages, Modules."
"errorCoachOff": "Le Coach est désactivé. Vous pouvez le réactiver dans Réglages, Modules.",
"frame": {
"showPanel": "Afficher les conversations",
"hidePanel": "Masquer les conversations",
"panelDescription": "Tes conversations avec le coach, les plus récentes d’abord.",
"rowActions": "Actions pour {title}",
"rename": "Renommer",
"delete": "Supprimer",
"modelSection": "Modèle",
"provider": "Répond avec",
"modelManaged": "Le modèle de ce fournisseur se règle dans les paramètres IA.",
"noProvider": "Aucun fournisseur disponible",
"saving": "Enregistrement…",
"saveFailed": "Impossible d’enregistrer cette modification.",
"allSettings": "Tous les paramètres IA",
"providerNotActive": "{provider} n'a pas pu prendre le relais : aucune connexion ni clé valide. Configurez-le dans les réglages IA.",
"operatorCodex": "ChatGPT (partagé par l'opérateur)",
"operatorManaged": "L'opérateur de votre serveur gère cette connexion. Dès qu'un de vos propres fournisseurs est configuré, c'est lui qui répond en premier."
},
"answer": {
"scrollToBottom": "Aller au dernier message",
"rememberDone": "Mémorisé",
"timeYesterday": "hier {time}",
"timeEarlier": "{date}, {time}",
"areasDoneOne": "1 domaine consulté",
"areasDoneFew": "{count} domaines consultés",
"areasDoneOther": "{count} domaines consultés"
}
},
"relativeJustNow": "à l’instant",
"relativeMinutesAgoOne": "il y a {count} minute",
@@ -5090,11 +5121,6 @@
"brief": "Bref",
"default": "Par défaut",
"detailed": "Détaillé",
"defaultWindow": "Période par défaut",
"last7days": "7 derniers jours",
"last30days": "30 derniers jours",
"last90days": "90 derniers jours",
"allTime": "Depuis toujours",
"excludedMetrics": "Mesures exclues",
"dataClusters": "Groupes de données",
"customClusters": "Utiliser une sélection personnalisée de groupes",
@@ -10431,11 +10457,13 @@
"searchPlaceholder": "Rechercher un titre ou un nom de fichier…",
"typeGroup": "Type de document",
"conditionGroup": "Affection",
"procedureGroup": "Interventions et chirurgies",
"yearGroup": "Année",
"clear": "Effacer les filtres",
"typeAll": "Tous les types",
"typeCount": "{count} types",
"conditionAll": "Toutes les affections",
"procedureAll": "Toutes les interventions",
"yearAll": "Toutes les années"
},
"card": {
@@ -10896,8 +10924,8 @@
},
"daily": {
"line": {
"score": "Ton score de santé aujourd'hui est de {score}.",
"allClear": "Rien ne requiert ton attention aujourd'hui."
"allClear": "Rien ne requiert ton attention aujourd'hui.",
"scoreReady": "Ton score de santé du jour est prêt."
},
"push": {
"title": "Votre résumé du matin",
@@ -10983,7 +11011,30 @@
"sleepPending": "Le sommeil de la nuit dernière n'est pas encore arrivé — la lecture du jour est provisoire.",
"worthALook": "À regarder",
"allClear": "Rien ne requiert ton attention aujourd'hui.",
"ringLink": "Ouvrir les détails de {metric}"
"ringLink": "Ouvrir les détails de {metric}",
"heading": "Aujourd'hui",
"steadyWeeksOne": "Stable depuis {count} semaine",
"steadyWeeksFew": "Stable depuis {count} semaines",
"steadyWeeksOther": "Stable depuis {count} semaines",
"valueWithUnit": "{value} {unit}",
"rangeWithUnit": "{low} à {high} {unit}",
"vital": {
"RESTING_HEART_RATE": "Fréquence cardiaque au repos",
"HEART_RATE_VARIABILITY": "Variabilité de la fréquence cardiaque",
"RESPIRATORY_RATE": "Fréquence respiratoire",
"OXYGEN_SATURATION": "Saturation en oxygène",
"BODY_TEMPERATURE": "Température corporelle",
"SKIN_TEMPERATURE": "Température cutanée",
"BLOOD_PRESSURE_SYS": "Tension systolique",
"BLOOD_PRESSURE_DIA": "Tension diastolique",
"BLOOD_GLUCOSE": "Glycémie",
"PULSE": "Pouls",
"WEIGHT": "Poids",
"generic": "Un signe vital"
},
"steadyAtLeastWeeksOne": "Stable depuis au moins {count} semaine",
"steadyAtLeastWeeksFew": "Stable depuis au moins {count} semaines",
"steadyAtLeastWeeksOther": "Stable depuis au moins {count} semaines"
},
"milestone": {
"metric": {
@@ -11019,6 +11070,68 @@
"body": "Un mois entier avec votre {metric} dans votre plage habituelle. Merveilleusement stable."
}
}
},
"lead": {
"vitalOutside": {
"above": "{metric} est à {value}, au-dessus de ta plage habituelle de {range}.",
"below": "{metric} est à {value}, en dessous de ta plage habituelle de {range}."
},
"vitalsOutside": {
"aboveOne": "{metric} est à {value}, au-dessus de ta plage habituelle de {range}, et {count} autre signe vital sort aussi de sa plage.",
"aboveFew": "{metric} est à {value}, au-dessus de ta plage habituelle de {range}, et {count} autres signes vitaux sortent aussi de leur plage.",
"aboveOther": "{metric} est à {value}, au-dessus de ta plage habituelle de {range}, et {count} autres signes vitaux sortent aussi de leur plage.",
"belowOne": "{metric} est à {value}, en dessous de ta plage habituelle de {range}, et {count} autre signe vital sort aussi de sa plage.",
"belowFew": "{metric} est à {value}, en dessous de ta plage habituelle de {range}, et {count} autres signes vitaux sortent aussi de leur plage.",
"belowOther": "{metric} est à {value}, en dessous de ta plage habituelle de {range}, et {count} autres signes vitaux sortent aussi de leur plage."
},
"sleep": {
"more": "La nuit dernière, tu as dormi {duration}, {diff} de plus que d'habitude.",
"less": "La nuit dernière, tu as dormi {duration}, {diff} de moins que d'habitude.",
"usual": "La nuit dernière, tu as dormi {duration}, proche de ton habitude.",
"plain": "La nuit dernière, tu as dormi {duration}."
},
"vitalsInRangeOne": "Ton dernier signe vital est dans sa plage habituelle.",
"vitalsInRangeFew": "Tes {count} derniers signes vitaux sont tous dans leur plage habituelle.",
"vitalsInRangeOther": "Tes {count} derniers signes vitaux sont tous dans leur plage habituelle.",
"vitalInRange": "{metric} est à {value}, dans ta plage habituelle de {range}."
},
"todayFact": {
"restMode": {
"label": "Mode repos",
"value": "Jour {day}"
},
"medications": {
"label": "Médicaments",
"value": "{taken} sur {scheduled} pris"
},
"appointment": {
"label": "Rendez-vous",
"today": "Aujourd'hui {time}, {what}",
"tomorrow": "Demain {time}, {what}"
},
"sleep": {
"label": "La nuit dernière",
"plain": "{duration}",
"usual": "{duration}, proche de ton habitude",
"more": "{duration}, {diff} de plus que d'habitude",
"less": "{duration}, {diff} de moins que d'habitude"
},
"vitals": {
"label": "Signes vitaux",
"allInOne": "{count} vérifié, dans ta plage",
"allInFew": "{count} vérifiés, tous dans ta plage",
"allInOther": "{count} vérifiés, tous dans ta plage",
"oneOut": {
"above": "{metric} au-dessus de ta plage",
"below": "{metric} en dessous de ta plage"
},
"someOut": "{count} sur {total} hors de ta plage"
},
"cycle": {
"label": "Cycle",
"value": "{phase}, jour {day}",
"dayOnly": "Jour {day}"
}
}
},
"reportSelection": {
+138 -25
View File
@@ -2877,7 +2877,6 @@
"sourcesFresh": "Aggiornate",
"sourcesStale": "Datate",
"sourcesFooter": "Il coach legge solo i dati che hai collegato.",
"evidenceLabel": "Su cosa si basa?",
"metric": {
"bp": "Pressione arteriosa",
"weight": "Peso",
@@ -2923,14 +2922,6 @@
"skin_temp": "Temperatura cutanea",
"workouts": "Allenamenti"
},
"window": {
"last7days": "Ultimi 7 giorni",
"last30days": "Ultimi 30 giorni",
"last90days": "Ultimi 90 giorni",
"lastYear": "ultimi 12 mesi",
"allTime": "tutto il tempo (12 mesi in dettaglio)"
},
"windowLabel": "Periodo",
"settingsAriaLabel": "Impostazioni del coach",
"settingsTitle": "Impostazioni del coach",
"settingsDescription": "Imposta come il coach ti parla.",
@@ -2943,8 +2934,9 @@
"settingsVerbosityDefault": "Standard",
"settingsVerbosityDetailed": "Dettagliata",
"settingsVerbosityConciseHint": "Il tono conciso mantiene già le risposte brevi: la verbosità resta fissata su «Breve» finché è attivo.",
"settingsSourcesPointer": "I gruppi di dati che il coach può usare e la finestra di analisi predefinita si gestiscono direttamente nella chat del coach (pannello «Su cosa mi baso»).",
"settingsSourcesPointerLink": "Apri il coach",
"settingsSourcesSummary": "Il coach vede {count} aree di dati su {total} e guarda indietro di {window}.",
"settingsSourcesSummaryAll": "Il coach vede {count} aree di dati su {total} e l’intero storico.",
"settingsSourcesPointerLink": "Apri le impostazioni del coach",
"settingsExcludeLabel": "Escludi metriche",
"settingsExcludeHint": "Il coach non vede i dati delle metriche selezionate.",
"settingsContextLabel": "Invia contesto",
@@ -2956,10 +2948,6 @@
"settingsSave": "Salva",
"settingsSaved": "Salvato",
"settingsCancel": "Annulla",
"feedbackHelpful": "Utile",
"feedbackUnhelpful": "Poco utile",
"feedbackThanks": "Grazie per il segnale.",
"feedbackError": "Impossibile salvare il feedback",
"dailyLimitTitle": "Limite giornaliero raggiunto",
"dailyLimitBody": "Limite giornaliero raggiunto. Il budget si reimposta a mezzanotte (UTC).",
"providerRateLimitTitle": "Provider con rate-limit",
@@ -3009,7 +2997,23 @@
"settingsClustersLabel": "Dati che il Coach vede",
"settingsClustersHint": "Scegli quali gruppi di dati il Coach può leggere. Più dati significano risposte più ricche e un prompt più grande.",
"settingsClusterNoData": "Ancora nessun dato",
"sourcesMemberCount": "{count} metriche",
"sourcesMemberCountOne": "1 metrica",
"sourcesMemberCountFew": "{count} metriche",
"sourcesMemberCountOther": "{count} metriche",
"lookback": {
"label": "Fin dove il Coach può guardare indietro",
"option": {
"last7days": "{days} giorni",
"last30days": "{days} giorni",
"last90days": "{days} giorni",
"lastYear": "{months} mesi",
"allTime": "Tutto"
},
"detailDays": "Il Coach non legge nulla di più vecchio di {days} giorni, in nessuna risposta.",
"detailMonths": "Il Coach non legge nulla di più vecchio di {months} mesi, in nessuna risposta.",
"detailAll": "Tutto ciò che hai registrato: {dailyMonths} mesi giorno per giorno, le misurazioni più vecchie come valori mensili fino a {years} anni fa, gli esami degli ultimi {labMonths} mesi.",
"note": "Il tuo profilo, i farmaci attuali, una malattia in corso e le prossime visite restano visibili; i riepiloghi che richiedono più storico vengono accorciati o omessi. Le risposte date in precedenza dal Coach nelle conversazioni più vecchie possono ancora citare valori più vecchi."
},
"showConversations": "Mostra conversazioni",
"hideConversations": "Nascondi conversazioni",
"heroGreeting": "Chiedimi qualcosa sui tuoi dati",
@@ -3113,7 +3117,34 @@
"errorNotPermitted": "Il Coach non è disponibile in una cartella che qualcuno condivide con te.",
"errorUnavailable": "Il Coach non è riuscito a controllare le sue impostazioni. Riprova tra un momento.",
"errorOperatorOff": "L'IA è disattivata su questo server, quindi il Coach non può rispondere.",
"errorCoachOff": "Il Coach è disattivato. Puoi riattivarlo in Impostazioni, Moduli."
"errorCoachOff": "Il Coach è disattivato. Puoi riattivarlo in Impostazioni, Moduli.",
"frame": {
"showPanel": "Mostra conversazioni",
"hidePanel": "Nascondi conversazioni",
"panelDescription": "Le tue conversazioni con il coach, dalla più recente.",
"rowActions": "Azioni per {title}",
"rename": "Rinomina",
"delete": "Elimina",
"modelSection": "Modello",
"provider": "Risponde con",
"modelManaged": "Il modello di questo fornitore si imposta nelle impostazioni IA.",
"noProvider": "Nessun fornitore disponibile",
"saving": "Salvataggio…",
"saveFailed": "Impossibile salvare la modifica.",
"allSettings": "Tutte le impostazioni IA",
"providerNotActive": "{provider} non è subentrato: manca un accesso o una chiave funzionante. Configuralo nelle impostazioni IA.",
"operatorCodex": "ChatGPT (condiviso dall'operatore)",
"operatorManaged": "Questa connessione è gestita dall'operatore del tuo server. Quando configuri uno dei tuoi fornitori, risponde per primo."
},
"answer": {
"scrollToBottom": "Vai all'ultimo messaggio",
"rememberDone": "Memorizzato",
"timeYesterday": "ieri {time}",
"timeEarlier": "{date}, {time}",
"areasDoneOne": "1 area consultata",
"areasDoneFew": "{count} aree consultate",
"areasDoneOther": "{count} aree consultate"
}
},
"relativeJustNow": "proprio ora",
"relativeMinutesAgoOne": "{count} minuto fa",
@@ -5090,11 +5121,6 @@
"brief": "Breve",
"default": "Predefinito",
"detailed": "Dettagliato",
"defaultWindow": "Periodo predefinito",
"last7days": "Ultimi 7 giorni",
"last30days": "Ultimi 30 giorni",
"last90days": "Ultimi 90 giorni",
"allTime": "Tutto il periodo",
"excludedMetrics": "Metriche escluse",
"dataClusters": "Gruppi di dati",
"customClusters": "Usa una selezione personalizzata di gruppi",
@@ -10431,11 +10457,13 @@
"searchPlaceholder": "Cerca titolo o nome file…",
"typeGroup": "Tipo di documento",
"conditionGroup": "Patologia",
"procedureGroup": "Procedure e interventi chirurgici",
"yearGroup": "Anno",
"clear": "Cancella filtri",
"typeAll": "Tutti i tipi",
"typeCount": "{count} tipi",
"conditionAll": "Tutte le patologie",
"procedureAll": "Tutte le procedure",
"yearAll": "Tutti gli anni"
},
"card": {
@@ -10896,8 +10924,8 @@
},
"daily": {
"line": {
"score": "Il tuo punteggio di salute oggi è {score}.",
"allClear": "Oggi nulla richiede la tua attenzione."
"allClear": "Oggi nulla richiede la tua attenzione.",
"scoreReady": "Il tuo punteggio di salute di oggi è pronto."
},
"push": {
"title": "Il tuo riepilogo mattutino",
@@ -10983,7 +11011,30 @@
"sleepPending": "Il sonno di stanotte non è ancora arrivato: la lettura di oggi è provvisoria.",
"worthALook": "Da tenere d'occhio",
"allClear": "Oggi nulla richiede la tua attenzione.",
"ringLink": "Apri i dettagli di {metric}"
"ringLink": "Apri i dettagli di {metric}",
"heading": "Oggi",
"steadyWeeksOne": "Stabile da {count} settimana",
"steadyWeeksFew": "Stabile da {count} settimane",
"steadyWeeksOther": "Stabile da {count} settimane",
"valueWithUnit": "{value} {unit}",
"rangeWithUnit": "da {low} a {high} {unit}",
"vital": {
"RESTING_HEART_RATE": "Frequenza cardiaca a riposo",
"HEART_RATE_VARIABILITY": "Variabilità della frequenza cardiaca",
"RESPIRATORY_RATE": "Frequenza respiratoria",
"OXYGEN_SATURATION": "Saturazione di ossigeno",
"BODY_TEMPERATURE": "Temperatura corporea",
"SKIN_TEMPERATURE": "Temperatura cutanea",
"BLOOD_PRESSURE_SYS": "Pressione sistolica",
"BLOOD_PRESSURE_DIA": "Pressione diastolica",
"BLOOD_GLUCOSE": "Glicemia",
"PULSE": "Polso",
"WEIGHT": "Peso",
"generic": "Un parametro vitale"
},
"steadyAtLeastWeeksOne": "Stabile da almeno {count} settimana",
"steadyAtLeastWeeksFew": "Stabile da almeno {count} settimane",
"steadyAtLeastWeeksOther": "Stabile da almeno {count} settimane"
},
"milestone": {
"metric": {
@@ -11019,6 +11070,68 @@
"body": "Un mese intero con la tua {metric} nel tuo intervallo abituale. Meravigliosamente costante."
}
}
},
"lead": {
"vitalOutside": {
"above": "{metric} è a {value}, sopra il tuo intervallo abituale di {range}.",
"below": "{metric} è a {value}, sotto il tuo intervallo abituale di {range}."
},
"vitalsOutside": {
"aboveOne": "{metric} è a {value}, sopra il tuo intervallo abituale di {range}, e anche {count} altro parametro vitale è fuori dal suo intervallo.",
"aboveFew": "{metric} è a {value}, sopra il tuo intervallo abituale di {range}, e anche altri {count} parametri vitali sono fuori dal loro intervallo.",
"aboveOther": "{metric} è a {value}, sopra il tuo intervallo abituale di {range}, e anche altri {count} parametri vitali sono fuori dal loro intervallo.",
"belowOne": "{metric} è a {value}, sotto il tuo intervallo abituale di {range}, e anche {count} altro parametro vitale è fuori dal suo intervallo.",
"belowFew": "{metric} è a {value}, sotto il tuo intervallo abituale di {range}, e anche altri {count} parametri vitali sono fuori dal loro intervallo.",
"belowOther": "{metric} è a {value}, sotto il tuo intervallo abituale di {range}, e anche altri {count} parametri vitali sono fuori dal loro intervallo."
},
"sleep": {
"more": "Stanotte hai dormito {duration}, {diff} in più del solito.",
"less": "Stanotte hai dormito {duration}, {diff} in meno del solito.",
"usual": "Stanotte hai dormito {duration}, vicino al tuo solito.",
"plain": "Stanotte hai dormito {duration}."
},
"vitalsInRangeOne": "Il tuo ultimo parametro vitale è nel suo intervallo abituale.",
"vitalsInRangeFew": "Tutti i tuoi {count} ultimi parametri vitali sono nel loro intervallo abituale.",
"vitalsInRangeOther": "Tutti i tuoi {count} ultimi parametri vitali sono nel loro intervallo abituale.",
"vitalInRange": "{metric} è a {value}, nel tuo intervallo abituale di {range}."
},
"todayFact": {
"restMode": {
"label": "Modalità riposo",
"value": "Giorno {day}"
},
"medications": {
"label": "Farmaci",
"value": "{taken} di {scheduled} presi"
},
"appointment": {
"label": "Appuntamento",
"today": "Oggi {time}, {what}",
"tomorrow": "Domani {time}, {what}"
},
"sleep": {
"label": "Stanotte",
"plain": "{duration}",
"usual": "{duration}, vicino al solito",
"more": "{duration}, {diff} in più del solito",
"less": "{duration}, {diff} in meno del solito"
},
"vitals": {
"label": "Parametri vitali",
"allInOne": "{count} controllato, nel tuo intervallo",
"allInFew": "{count} controllati, tutti nel tuo intervallo",
"allInOther": "{count} controllati, tutti nel tuo intervallo",
"oneOut": {
"above": "{metric} sopra il tuo intervallo",
"below": "{metric} sotto il tuo intervallo"
},
"someOut": "{count} su {total} fuori dal tuo intervallo"
},
"cycle": {
"label": "Ciclo",
"value": "{phase}, giorno {day}",
"dayOnly": "Giorno {day}"
}
}
},
"reportSelection": {
+138 -25
View File
@@ -2877,7 +2877,6 @@
"sourcesFresh": "최신",
"sourcesStale": "오래됨",
"sourcesFooter": "코치는 연결한 데이터만 읽어요.",
"evidenceLabel": "내가 보고 있는 것",
"metric": {
"bp": "혈압",
"weight": "체중",
@@ -2923,14 +2922,6 @@
"skin_temp": "피부 온도",
"workouts": "운동"
},
"window": {
"last7days": "최근 7일",
"last30days": "최근 30일",
"last90days": "최근 90일",
"lastYear": "최근 12개월",
"allTime": "전체 기간(상세 12개월)"
},
"windowLabel": "기간",
"settingsAriaLabel": "코치 설정",
"settingsTitle": "코치 설정",
"settingsDescription": "코치가 대화하는 방식을 조절해요.",
@@ -2943,8 +2934,9 @@
"settingsVerbosityDefault": "기본",
"settingsVerbosityDetailed": "자세히",
"settingsVerbosityConciseHint": "간결한 말투는 이미 답변을 짧게 유지해요 — 켜 두는 동안 답변 길이는 '짧게'로 고정돼요.",
"settingsSourcesPointer": "코치가 참고할 데이터 묶음과 기본 분석 기간은 코치 대화의 '내가 참고하는 것' 레일에서 바로 관리해요.",
"settingsSourcesPointerLink": "코치 열기",
"settingsSourcesSummary": "코치는 데이터 영역 {total}개 중 {count}개를 {window} 전까지 봐요.",
"settingsSourcesSummaryAll": "코치는 데이터 영역 {total}개 중 {count}개를 처음 기록부터 봐요.",
"settingsSourcesPointerLink": "코치 설정 열기",
"settingsExcludeLabel": "제외할 지표",
"settingsExcludeHint": "선택한 지표의 데이터는 코치가 보지 않아요.",
"settingsContextLabel": "포함할 맥락",
@@ -2956,10 +2948,6 @@
"settingsSave": "저장",
"settingsSaved": "저장됨",
"settingsCancel": "취소",
"feedbackHelpful": "도움됨",
"feedbackUnhelpful": "아쉬움",
"feedbackThanks": "알려줘서 고마워요.",
"feedbackError": "피드백을 저장하지 못했어요",
"dailyLimitTitle": "하루 한도 도달",
"dailyLimitBody": "하루 한도에 도달했어요. 사용량은 UTC 자정에 초기화돼요.",
"providerRateLimitTitle": "제공자 요청 제한",
@@ -3009,7 +2997,23 @@
"settingsClustersLabel": "코치가 보는 데이터",
"settingsClustersHint": "코치가 읽을 수 있는 데이터 묶음을 고르세요. 데이터가 많을수록 답변이 풍부해지고 프롬프트도 커져요.",
"settingsClusterNoData": "아직 데이터 없음",
"sourcesMemberCount": "지표 {count}개",
"sourcesMemberCountOne": "지표 1개",
"sourcesMemberCountFew": "지표 {count}개",
"sourcesMemberCountOther": "지표 {count}개",
"lookback": {
"label": "코치가 돌아볼 수 있는 기간",
"option": {
"last7days": "{days}일",
"last30days": "{days}일",
"last90days": "{days}일",
"lastYear": "{months}개월",
"allTime": "전체"
},
"detailDays": "코치는 어떤 답변에서도 {days}일보다 오래된 기록은 읽지 않아요.",
"detailMonths": "코치는 어떤 답변에서도 {months}개월보다 오래된 기록은 읽지 않아요.",
"detailAll": "기록한 모든 것: 최근 {dailyMonths}개월은 하루 단위로, 그 이전 측정값은 최대 {years}년 전까지 월별 값으로, 검사 결과는 최근 {labMonths}개월 치를 읽어요.",
"note": "프로필, 현재 복용 중인 약, 진행 중인 질병, 예정된 진료는 계속 보여요. 더 긴 기록이 필요한 요약은 줄이거나 빼요. 예전 대화에서 코치가 이미 한 답변에는 더 오래된 값이 남아 있을 수 있어요."
},
"showConversations": "대화 보기",
"hideConversations": "대화 숨기기",
"heroGreeting": "내 데이터에 대해 무엇이든 물어보세요",
@@ -3113,7 +3117,34 @@
"errorNotPermitted": "다른 사람이 공유한 기록 안에서는 코치를 사용할 수 없습니다.",
"errorUnavailable": "코치가 지금 설정을 확인하지 못했습니다. 잠시 후 다시 시도해 주세요.",
"errorOperatorOff": "이 서버에서 AI가 꺼져 있어 코치가 답변할 수 없습니다.",
"errorCoachOff": "코치가 꺼져 있습니다. 설정의 모듈에서 다시 켤 수 있습니다."
"errorCoachOff": "코치가 꺼져 있습니다. 설정의 모듈에서 다시 켤 수 있습니다.",
"frame": {
"showPanel": "대화 목록 보기",
"hidePanel": "대화 목록 숨기기",
"panelDescription": "코치와 나눈 대화, 최신순이에요.",
"rowActions": "{title} 작업",
"rename": "이름 바꾸기",
"delete": "삭제",
"modelSection": "모델",
"provider": "응답 제공자",
"modelManaged": "이 제공자의 모델은 AI 설정에서 정해요.",
"noProvider": "사용할 수 있는 제공자가 없어요",
"saving": "저장 중…",
"saveFailed": "변경 사항을 저장하지 못했어요.",
"allSettings": "모든 AI 설정",
"providerNotActive": "{provider}(으)로 전환하지 못했습니다. 작동하는 로그인이나 키가 없습니다. AI 설정에서 설정하세요.",
"operatorCodex": "ChatGPT (운영자 공유)",
"operatorManaged": "이 연결은 서버 운영자가 관리합니다. 내 공급자를 하나라도 설정하면 그 공급자가 먼저 답합니다."
},
"answer": {
"scrollToBottom": "최신 메시지로 이동",
"rememberDone": "기억함",
"timeYesterday": "어제 {time}",
"timeEarlier": "{date} {time}",
"areasDoneOne": "영역 1개 확인",
"areasDoneFew": "영역 {count}개 확인",
"areasDoneOther": "영역 {count}개 확인"
}
},
"relativeJustNow": "방금",
"relativeMinutesAgoOne": "{count}분 전",
@@ -5090,11 +5121,6 @@
"brief": "간략",
"default": "기본",
"detailed": "상세",
"defaultWindow": "기본 기간",
"last7days": "최근 7일",
"last30days": "최근 30일",
"last90days": "최근 90일",
"allTime": "전체 기간",
"excludedMetrics": "제외할 지표",
"dataClusters": "데이터 그룹",
"customClusters": "그룹 직접 선택하기",
@@ -10431,11 +10457,13 @@
"searchPlaceholder": "제목이나 파일명 검색…",
"typeGroup": "문서 종류",
"conditionGroup": "질환",
"procedureGroup": "시술 및 수술",
"yearGroup": "연도",
"clear": "필터 해제",
"typeAll": "모든 종류",
"typeCount": "{count}개 종류",
"conditionAll": "모든 질환",
"procedureAll": "모든 시술",
"yearAll": "모든 연도"
},
"card": {
@@ -10896,8 +10924,8 @@
},
"daily": {
"line": {
"score": "오늘 건강 점수는 {score}점이에요.",
"allClear": "오늘은 신경 쓸 일이 없어요."
"allClear": "오늘은 신경 쓸 일이 없어요.",
"scoreReady": "오늘의 건강 점수가 준비됐어요."
},
"push": {
"title": "아침 브리핑",
@@ -10983,7 +11011,30 @@
"sleepPending": "어젯밤 수면이 아직 들어오지 않아 오늘 결과는 잠정치예요.",
"worthALook": "살펴볼 만한 것",
"allClear": "오늘은 신경 쓸 일이 없어요.",
"ringLink": "{metric} 자세히 보기"
"ringLink": "{metric} 자세히 보기",
"heading": "오늘",
"steadyWeeksOne": "{count}주째 안정적",
"steadyWeeksFew": "{count}주째 안정적",
"steadyWeeksOther": "{count}주째 안정적",
"valueWithUnit": "{value} {unit}",
"rangeWithUnit": "{low}~{high} {unit}",
"vital": {
"RESTING_HEART_RATE": "안정 시 심박수",
"HEART_RATE_VARIABILITY": "심박 변이도",
"RESPIRATORY_RATE": "호흡수",
"OXYGEN_SATURATION": "산소포화도",
"BODY_TEMPERATURE": "체온",
"SKIN_TEMPERATURE": "피부 온도",
"BLOOD_PRESSURE_SYS": "수축기 혈압",
"BLOOD_PRESSURE_DIA": "이완기 혈압",
"BLOOD_GLUCOSE": "혈당",
"PULSE": "맥박",
"WEIGHT": "체중",
"generic": "생체 지표"
},
"steadyAtLeastWeeksOne": "최소 {count}주째 안정적",
"steadyAtLeastWeeksFew": "최소 {count}주째 안정적",
"steadyAtLeastWeeksOther": "최소 {count}주째 안정적"
},
"milestone": {
"metric": {
@@ -11019,6 +11070,68 @@
"body": "{metric} 수치가 꼬박 한 달 동안 평소 범위 안이었어요. 아주 꾸준해요."
}
}
},
"lead": {
"vitalOutside": {
"above": "{metric}이(가) {value}로 평소 범위({range})보다 높아요.",
"below": "{metric}이(가) {value}로 평소 범위({range})보다 낮아요."
},
"vitalsOutside": {
"aboveOne": "{metric}이(가) {value}로 평소 범위({range})보다 높고, 다른 지표 {count}개도 범위를 벗어났어요.",
"aboveFew": "{metric}이(가) {value}로 평소 범위({range})보다 높고, 다른 지표 {count}개도 범위를 벗어났어요.",
"aboveOther": "{metric}이(가) {value}로 평소 범위({range})보다 높고, 다른 지표 {count}개도 범위를 벗어났어요.",
"belowOne": "{metric}이(가) {value}로 평소 범위({range})보다 낮고, 다른 지표 {count}개도 범위를 벗어났어요.",
"belowFew": "{metric}이(가) {value}로 평소 범위({range})보다 낮고, 다른 지표 {count}개도 범위를 벗어났어요.",
"belowOther": "{metric}이(가) {value}로 평소 범위({range})보다 낮고, 다른 지표 {count}개도 범위를 벗어났어요."
},
"sleep": {
"more": "어젯밤 {duration} 잤어요. 평소보다 {diff} 더 잤어요.",
"less": "어젯밤 {duration} 잤어요. 평소보다 {diff} 덜 잤어요.",
"usual": "어젯밤 {duration} 잤어요. 평소와 비슷해요.",
"plain": "어젯밤 {duration} 잤어요."
},
"vitalsInRangeOne": "최근 생체 지표가 평소 범위 안에 있어요.",
"vitalsInRangeFew": "최근 생체 지표 {count}개가 모두 평소 범위 안에 있어요.",
"vitalsInRangeOther": "최근 생체 지표 {count}개가 모두 평소 범위 안에 있어요.",
"vitalInRange": "{metric}이(가) {value}로 평소 범위({range}) 안에 있어요."
},
"todayFact": {
"restMode": {
"label": "휴식 모드",
"value": "{day}일째"
},
"medications": {
"label": "복약",
"value": "{scheduled}회 중 {taken}회 복용"
},
"appointment": {
"label": "예약",
"today": "오늘 {time}, {what}",
"tomorrow": "내일 {time}, {what}"
},
"sleep": {
"label": "어젯밤",
"plain": "{duration}",
"usual": "{duration}, 평소와 비슷",
"more": "{duration}, 평소보다 {diff} 더",
"less": "{duration}, 평소보다 {diff} 덜"
},
"vitals": {
"label": "생체 지표",
"allInOne": "{count}개 확인, 범위 안",
"allInFew": "{count}개 확인, 모두 범위 안",
"allInOther": "{count}개 확인, 모두 범위 안",
"oneOut": {
"above": "{metric} 범위보다 높음",
"below": "{metric} 범위보다 낮음"
},
"someOut": "{total}개 중 {count}개 범위 밖"
},
"cycle": {
"label": "주기",
"value": "{phase}, {day}일째",
"dayOnly": "{day}일째"
}
}
},
"reportSelection": {
+138 -25
View File
@@ -2877,7 +2877,6 @@
"sourcesFresh": "Aktualne",
"sourcesStale": "Nieaktualne",
"sourcesFooter": "Coach czyta tylko dane, które podłączyłeś.",
"evidenceLabel": "Na czym to się opiera?",
"metric": {
"bp": "Ciśnienie krwi",
"weight": "Waga",
@@ -2923,14 +2922,6 @@
"skin_temp": "Temperatura skóry",
"workouts": "Treningi"
},
"window": {
"last7days": "Ostatnie 7 dni",
"last30days": "Ostatnie 30 dni",
"last90days": "Ostatnie 90 dni",
"lastYear": "ostatnie 12 miesięcy",
"allTime": "cały okres (12 miesięcy szczegółowo)"
},
"windowLabel": "Okres",
"settingsAriaLabel": "Ustawienia coacha",
"settingsTitle": "Ustawienia coacha",
"settingsDescription": "Ustaw, jak coach do ciebie mówi.",
@@ -2943,8 +2934,9 @@
"settingsVerbosityDefault": "Standardowa",
"settingsVerbosityDetailed": "Szczegółowa",
"settingsVerbosityConciseHint": "Zwięzły ton i tak skraca odpowiedzi — szczegółowość pozostaje ustawiona na „Krótko”, dopóki jest aktywny.",
"settingsSourcesPointer": "Tym, z jakich grup danych może korzystać coach, oraz domyślnym oknem analizy zarządzasz bezpośrednio w czacie coacha (panel „Z czego korzystam”).",
"settingsSourcesPointerLink": "Otwórz coacha",
"settingsSourcesSummary": "Coach widzi {count} z {total} obszarów danych i patrzy {window} wstecz.",
"settingsSourcesSummaryAll": "Coach widzi {count} z {total} obszarów danych i całą historię.",
"settingsSourcesPointerLink": "Otwórz ustawienia coacha",
"settingsExcludeLabel": "Wyklucz metryki",
"settingsExcludeHint": "Coach nie zobaczy danych wybranych metryk.",
"settingsContextLabel": "Wysyłaj kontekst",
@@ -2956,10 +2948,6 @@
"settingsSave": "Zapisz",
"settingsSaved": "Zapisano",
"settingsCancel": "Anuluj",
"feedbackHelpful": "Przydatne",
"feedbackUnhelpful": "Mało przydatne",
"feedbackThanks": "Dzięki za sygnał.",
"feedbackError": "Nie udało się zapisać opinii",
"dailyLimitTitle": "Osiągnięto dzienny limit",
"dailyLimitBody": "Osiągnięto dzienny limit. Limit resetuje się o północy (UTC).",
"providerRateLimitTitle": "Dostawca z ograniczeniem",
@@ -3009,7 +2997,23 @@
"settingsClustersLabel": "Dane widziane przez Coacha",
"settingsClustersHint": "Wybierz, które grupy danych Coach może czytać. Więcej danych to bogatsze odpowiedzi i większy prompt.",
"settingsClusterNoData": "Brak danych",
"sourcesMemberCount": "{count} wskaźników",
"sourcesMemberCountOne": "1 wskaźnik",
"sourcesMemberCountFew": "{count} wskaźniki",
"sourcesMemberCountOther": "{count} wskaźników",
"lookback": {
"label": "Jak daleko wstecz może patrzeć Coach",
"option": {
"last7days": "{days} dni",
"last30days": "{days} dni",
"last90days": "{days} dni",
"lastYear": "{months} miesięcy",
"allTime": "Wszystko"
},
"detailDays": "Coach nie czyta niczego starszego niż {days} dni, w żadnej odpowiedzi.",
"detailMonths": "Coach nie czyta niczego starszego niż {months} miesięcy, w żadnej odpowiedzi.",
"detailAll": "Wszystko, co zostało zapisane: {dailyMonths} miesięcy z dokładnością do dnia, starsze pomiary jako wartości miesięczne do {years} lat wstecz, wyniki badań z ostatnich {labMonths} miesięcy.",
"note": "Twój profil, obecne leki, trwająca choroba i nadchodzące wizyty pozostają widoczne; podsumowania wymagające dłuższej historii są skracane lub pomijane. Odpowiedzi, których Coach udzielił wcześniej w starszych rozmowach, mogą nadal przytaczać starsze wartości."
},
"showConversations": "Pokaż rozmowy",
"hideConversations": "Ukryj rozmowy",
"heroGreeting": "Zapytaj mnie o cokolwiek na temat twoich danych",
@@ -3113,7 +3117,34 @@
"errorNotPermitted": "Coach nie jest dostępny w kartotece, którą ktoś Ci udostępnił.",
"errorUnavailable": "Coach nie mógł teraz sprawdzić swoich ustawień. Spróbuj ponownie za chwilę.",
"errorOperatorOff": "AI jest wyłączone na tym serwerze, więc Coach nie może odpowiedzieć.",
"errorCoachOff": "Coach jest wyłączony. Możesz go ponownie włączyć w Ustawieniach, Moduły."
"errorCoachOff": "Coach jest wyłączony. Możesz go ponownie włączyć w Ustawieniach, Moduły.",
"frame": {
"showPanel": "Pokaż rozmowy",
"hidePanel": "Ukryj rozmowy",
"panelDescription": "Twoje rozmowy z coachem, od najnowszej.",
"rowActions": "Działania dla {title}",
"rename": "Zmień nazwę",
"delete": "Usuń",
"modelSection": "Model",
"provider": "Odpowiada przez",
"modelManaged": "Model tego dostawcy ustawisz w ustawieniach AI.",
"noProvider": "Brak dostępnego dostawcy",
"saving": "Zapisywanie…",
"saveFailed": "Nie udało się zapisać zmiany.",
"allSettings": "Wszystkie ustawienia AI",
"providerNotActive": "{provider} nie mógł przejąć odpowiedzi: brak działającego logowania lub klucza. Skonfiguruj go w ustawieniach AI.",
"operatorCodex": "ChatGPT (udostępniony przez operatora)",
"operatorManaged": "Tym połączeniem zarządza operator Twojego serwera. Gdy skonfigurujesz własnego dostawcę, to on odpowie jako pierwszy."
},
"answer": {
"scrollToBottom": "Przejdź do najnowszej wiadomości",
"rememberDone": "Zapamiętano",
"timeYesterday": "wczoraj {time}",
"timeEarlier": "{date}, {time}",
"areasDoneOne": "Sprawdzono 1 obszar",
"areasDoneFew": "Sprawdzono {count} obszary",
"areasDoneOther": "Sprawdzono {count} obszarów"
}
},
"relativeJustNow": "przed chwilą",
"relativeMinutesAgoOne": "{count} minutę temu",
@@ -5090,11 +5121,6 @@
"brief": "Krótka",
"default": "Domyślna",
"detailed": "Szczegółowa",
"defaultWindow": "Domyślny okres",
"last7days": "Ostatnie 7 dni",
"last30days": "Ostatnie 30 dni",
"last90days": "Ostatnie 90 dni",
"allTime": "Cały okres",
"excludedMetrics": "Wykluczone metryki",
"dataClusters": "Grupy danych",
"customClusters": "Użyj własnego wyboru grup",
@@ -10431,11 +10457,13 @@
"searchPlaceholder": "Szukaj tytułu lub nazwy pliku…",
"typeGroup": "Typ dokumentu",
"conditionGroup": "Schorzenie",
"procedureGroup": "Zabiegi i operacje",
"yearGroup": "Rok",
"clear": "Wyczyść filtry",
"typeAll": "Wszystkie typy",
"typeCount": "{count} typy",
"conditionAll": "Wszystkie schorzenia",
"procedureAll": "Wszystkie zabiegi",
"yearAll": "Wszystkie lata"
},
"card": {
@@ -10896,8 +10924,8 @@
},
"daily": {
"line": {
"score": "Twój dzisiejszy wynik zdrowia to {score}.",
"allClear": "Dziś nic nie wymaga Twojej uwagi."
"allClear": "Dziś nic nie wymaga Twojej uwagi.",
"scoreReady": "Twój dzisiejszy wynik zdrowia jest gotowy."
},
"push": {
"title": "Twoje poranne podsumowanie",
@@ -10983,7 +11011,30 @@
"sleepPending": "Sen z ostatniej nocy jeszcze nie dotarł — dzisiejszy odczyt jest wstępny.",
"worthALook": "Warto sprawdzić",
"allClear": "Dziś nic nie wymaga Twojej uwagi.",
"ringLink": "Otwórz szczegóły: {metric}"
"ringLink": "Otwórz szczegóły: {metric}",
"heading": "Dziś",
"steadyWeeksOne": "Stabilnie od {count} tygodnia",
"steadyWeeksFew": "Stabilnie od {count} tygodni",
"steadyWeeksOther": "Stabilnie od {count} tygodni",
"valueWithUnit": "{value} {unit}",
"rangeWithUnit": "{low} do {high} {unit}",
"vital": {
"RESTING_HEART_RATE": "Tętno spoczynkowe",
"HEART_RATE_VARIABILITY": "Zmienność rytmu serca",
"RESPIRATORY_RATE": "Częstość oddechów",
"OXYGEN_SATURATION": "Saturacja",
"BODY_TEMPERATURE": "Temperatura ciała",
"SKIN_TEMPERATURE": "Temperatura skóry",
"BLOOD_PRESSURE_SYS": "Ciśnienie skurczowe",
"BLOOD_PRESSURE_DIA": "Ciśnienie rozkurczowe",
"BLOOD_GLUCOSE": "Glukoza we krwi",
"PULSE": "Tętno",
"WEIGHT": "Waga",
"generic": "Parametr życiowy"
},
"steadyAtLeastWeeksOne": "Stabilnie od co najmniej {count} tygodnia",
"steadyAtLeastWeeksFew": "Stabilnie od co najmniej {count} tygodni",
"steadyAtLeastWeeksOther": "Stabilnie od co najmniej {count} tygodni"
},
"milestone": {
"metric": {
@@ -11019,6 +11070,68 @@
"body": "Cały miesiąc z {metric} w zwykłym zakresie. Wspaniale stabilnie."
}
}
},
"lead": {
"vitalOutside": {
"above": "{metric}: {value}, powyżej Twojego zwykłego zakresu {range}.",
"below": "{metric}: {value}, poniżej Twojego zwykłego zakresu {range}."
},
"vitalsOutside": {
"aboveOne": "{metric}: {value}, powyżej Twojego zwykłego zakresu {range}. Poza zakresem jest też {count} inny parametr.",
"aboveFew": "{metric}: {value}, powyżej Twojego zwykłego zakresu {range}. Poza zakresem są też {count} inne parametry.",
"aboveOther": "{metric}: {value}, powyżej Twojego zwykłego zakresu {range}. Poza zakresem jest też {count} innych parametrów.",
"belowOne": "{metric}: {value}, poniżej Twojego zwykłego zakresu {range}. Poza zakresem jest też {count} inny parametr.",
"belowFew": "{metric}: {value}, poniżej Twojego zwykłego zakresu {range}. Poza zakresem są też {count} inne parametry.",
"belowOther": "{metric}: {value}, poniżej Twojego zwykłego zakresu {range}. Poza zakresem jest też {count} innych parametrów."
},
"sleep": {
"more": "Ostatnia noc: {duration} snu, o {diff} dłużej niż zwykle.",
"less": "Ostatnia noc: {duration} snu, o {diff} krócej niż zwykle.",
"usual": "Ostatnia noc: {duration} snu, mniej więcej tyle co zwykle.",
"plain": "Ostatnia noc: {duration} snu."
},
"vitalsInRangeOne": "Twój ostatni parametr życiowy mieści się w zwykłym zakresie.",
"vitalsInRangeFew": "Wszystkie {count} ostatnie parametry życiowe mieszczą się w zwykłym zakresie.",
"vitalsInRangeOther": "Wszystkie {count} ostatnich parametrów życiowych mieści się w zwykłym zakresie.",
"vitalInRange": "{metric}: {value}, w Twoim zwykłym zakresie {range}."
},
"todayFact": {
"restMode": {
"label": "Tryb odpoczynku",
"value": "Dzień {day}"
},
"medications": {
"label": "Leki",
"value": "Przyjęto {taken} z {scheduled}"
},
"appointment": {
"label": "Wizyta",
"today": "Dziś {time}, {what}",
"tomorrow": "Jutro {time}, {what}"
},
"sleep": {
"label": "Ostatnia noc",
"plain": "{duration}",
"usual": "{duration}, jak zwykle",
"more": "{duration}, o {diff} dłużej niż zwykle",
"less": "{duration}, o {diff} krócej niż zwykle"
},
"vitals": {
"label": "Parametry życiowe",
"allInOne": "Sprawdzono {count}, w zakresie",
"allInFew": "Sprawdzono {count}, wszystkie w zakresie",
"allInOther": "Sprawdzono {count}, wszystkie w zakresie",
"oneOut": {
"above": "{metric} powyżej zakresu",
"below": "{metric} poniżej zakresu"
},
"someOut": "{count} z {total} poza zakresem"
},
"cycle": {
"label": "Cykl",
"value": "{phase}, dzień {day}",
"dayOnly": "Dzień {day}"
}
}
},
"reportSelection": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.40.1",
"version": "1.40.2",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.40.1";
/* @sw-version-fallback */ "v1.40.2";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
+35 -4
View File
@@ -1766,10 +1766,32 @@ async function seed() {
[convoId, userId, "Blood pressure trend", convoStart],
);
// The provenance uses the contract tokens (`bp`, `resting_hr`, `sleep`):
// the client names an area only through a known token, so a token it
// cannot name never reaches the page. The steps give the answer its
// "Looked at 3 sources" header the way a live turn would; they carry no
// counts, since a count is the server's tally and the seed does not
// run a tool.
const seedStep = (
id: string,
tool: string,
domain: string,
domainLabel: string,
) => ({
id,
tool,
labelKey: "coach.step.readWindow",
label: `Checking: ${domainLabel}, last 30 days`,
domain,
window: "last30days",
status: "done",
});
const coachTurns: Array<{
role: string;
content: string;
metricSourceJson?: string;
tokensUsed?: number;
model?: string;
}> = [
{
role: "user",
@@ -1785,9 +1807,16 @@ async function seed() {
"paying off — worth staying consistent with the morning measurements " +
"so the trend stays easy to read.",
metricSourceJson: JSON.stringify({
window: "last30days",
metrics: ["bloodPressure", "restingHeartRate", "sleep"],
windows: ["last30days"],
metrics: ["bp", "resting_hr", "sleep"],
steps: [
seedStep("s1", "get_metric_series", "bp", "Blood pressure"),
seedStep("s2", "get_metric_series", "resting_hr", "Resting HR"),
seedStep("s3", "get_sleep", "sleep", "Sleep"),
],
}),
tokensUsed: 1840,
model: aiModel,
},
];
for (let t = 0; t < coachTurns.length; t++) {
@@ -1795,8 +1824,8 @@ async function seed() {
const ts = new Date(convoStart.getTime() + t * 60_000);
const encrypted = Buffer.from(encryptToBytes(turn.content));
await client.query(
`INSERT INTO coach_messages (id, conversation_id, role, encrypted_content, metric_source_json, provider_type, prompt_version, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
`INSERT INTO coach_messages (id, conversation_id, role, encrypted_content, metric_source_json, provider_type, prompt_version, tokens_used, model, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
[
cuid(),
convoId,
@@ -1805,6 +1834,8 @@ async function seed() {
turn.metricSourceJson ?? null,
turn.role === "assistant" ? aiProvider : null,
turn.role === "assistant" ? "demo" : null,
turn.tokensUsed ?? null,
turn.model ?? null,
ts,
],
);
@@ -63,18 +63,26 @@ describe("Coach streaming lifecycle", () => {
"components/insights/coach-panel/coach-conversation.tsx",
);
// Isolate the HistoryRail onSelect callback body.
// One switch handler, shared by the drawer's history tray and the page's
// conversations panel, so neither surface can skip the reset.
const match = source.match(
/<HistoryRail[\s\S]*?onSelect=\{\([\s\S]*?\n\s*\}\}/,
/function selectConversation\(id: string\) \{[\s\S]*?\n {2}\}/,
);
expect(match, "HistoryRail onSelect handler not found").not.toBeNull();
const onSelectBody = match![0];
expect(match, "selectConversation handler not found").not.toBeNull();
expect(
onSelectBody.includes("send.reset()"),
match![0].includes("send.reset()"),
"selecting another conversation must call send.reset() so the previous " +
"thread's trailing stream state does not bleed into the new thread",
).toBe(true);
const selectSites = source.match(/onSelect=\{([^}]*)\}/g) ?? [];
expect(selectSites.length).toBeGreaterThanOrEqual(2);
for (const site of selectSites) {
expect(
site,
"every conversation picker routes through selectConversation",
).toBe("onSelect={selectConversation}");
}
});
it("the unmount cleanup records the interrupted conversation before the abort (#781)", () => {
@@ -173,7 +173,14 @@ const PINNED_AFFORDANCES: Record<
CheckCircle2: 2,
"toast.success": 1,
},
"src/components/insights/coach-panel/chat-bubble.tsx": { "text-success": 4 },
// The Coach message action row: the copy tick after the clipboard write
// resolved, and the remember icon and toast only for the self-context entry
// the tap wrote (`adopted`); an entry that was already there gets a neutral
// icon and `toast.info`.
"src/components/insights/coach-panel/message-actions.tsx": {
"text-success": 2,
"toast.success": 1,
},
// The copy tick on a result table: shown only after the clipboard write
// resolved, so it reports a copy that happened; nothing is written to the
// record.
+4
View File
@@ -28,6 +28,7 @@ import { prisma } from "@/lib/db";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { getOrCreateCycleProfile } from "@/lib/cycle/profile";
import { isCycleEnabled } from "@/lib/cycle/gate";
import { invalidateUserHealthContext } from "@/lib/cache/invalidate";
import { toCycleProfileDTO } from "@/lib/cycle/dto";
import { cyclePrefsSchema } from "@/lib/validations/cycle";
@@ -118,6 +119,9 @@ export const PATCH = apiHandler(async (req: Request) => {
});
const resolved = isCycleEnabled(user.gender, updated);
// The typical lengths, the prediction switch and tracking itself all feed
// the dashboard's cycle line, which is cached in the digest extras cell.
invalidateUserHealthContext(user.id);
await auditLog("user.cycle-prefs.update", {
userId: user.id,
@@ -71,6 +71,9 @@ const DIGEST: DailyDigest = {
},
topSignal: null,
briefingLead: "Blood pressure is holding steady.",
lead: { text: "Blood pressure is holding steady.", source: "briefing" },
today: [],
restMode: null,
line: "Blood pressure is holding steady.",
worthALook: [],
justIn: null,
@@ -580,6 +580,82 @@ describe("GET /api/documents/inbound (list)", () => {
expect(body.data.nextCursor).toBe("d1");
});
it("narrows to one procedure through the visit link, owner-scoped", async () => {
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([
docRow({ id: "d1" }),
] as never);
const res = await get(
new Request("http://localhost/api/documents/inbound?encounterId=enc-1"),
);
expect(res.status).toBe(200);
const where = vi.mocked(prisma.inboundDocument.findMany).mock.calls[0]![0]!
.where!;
// The link filter rides beside the owner and the tombstone predicates, so
// another account's visit id can only ever match nothing of this one.
expect(where).toEqual({
userId: "user-1",
deletedAt: null,
encounterLinks: { some: { encounterId: "enc-1" } },
});
});
it("AND-combines the procedure filter with kind, year and condition", async () => {
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([] as never);
const res = await get(
new Request(
"http://localhost/api/documents/inbound?encounterId=enc-1&kind=IMAGING&year=2025&episodeId=ep-1",
),
);
expect(res.status).toBe(200);
const where = vi.mocked(prisma.inboundDocument.findMany).mock.calls[0]![0]!
.where!;
expect(where.encounterLinks).toEqual({ some: { encounterId: "enc-1" } });
expect(where.conditionLinks).toEqual({ some: { episodeId: "ep-1" } });
expect(where.kind).toEqual({ in: ["IMAGING"] });
expect(where.documentDate).toEqual({
gte: new Date(Date.UTC(2025, 0, 1)),
lt: new Date(Date.UTC(2026, 0, 1)),
});
expect(where.userId).toBe("user-1");
});
it("pages a procedure-filtered list on the keyset cursor", async () => {
const rows = Array.from({ length: 3 }, (_, i) => docRow({ id: `d${i}` }));
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue(rows as never);
const res = await get(
new Request(
"http://localhost/api/documents/inbound?encounterId=enc-1&limit=2&cursor=d9",
),
);
const body = await res.json();
expect(body.data.documents.map((d: { id: string }) => d.id)).toEqual([
"d0",
"d1",
]);
expect(body.data.nextCursor).toBe("d1");
const arg = vi.mocked(prisma.inboundDocument.findMany).mock.calls[0]![0]!;
expect(arg.where!.encounterLinks).toEqual({
some: { encounterId: "enc-1" },
});
expect(arg.cursor).toEqual({ id: "d9" });
expect(arg.skip).toBe(1);
expect(arg.take).toBe(3);
});
it("422s an empty or over-long encounterId", async () => {
let res = await get(
new Request("http://localhost/api/documents/inbound?encounterId="),
);
expect(res.status).toBe(422);
res = await get(
new Request(
`http://localhost/api/documents/inbound?encounterId=${"x".repeat(41)}`,
),
);
expect(res.status).toBe(422);
expect(prisma.inboundDocument.findMany).not.toHaveBeenCalled();
});
it("422s on a bad sort value", async () => {
const res = await get(
new Request("http://localhost/api/documents/inbound?sort=nope"),
@@ -0,0 +1,202 @@
/**
* `GET /api/documents/inbound/usage` — the procedure choices of the vault's
* filter bar.
*
* The reason and body site are ciphertext in the fixture as they are in the
* database, so a route that published the stored bytes, or skipped the
* decrypt, goes red. The fake honours nothing about the `where`, so the test
* asserts the `where` it was handed: a read that forgot the kind, the owner,
* or the live-document condition would otherwise pass against a fixture of
* linked procedures only.
*
* Mutation checks (each run, each seen red):
* - drop `kind: "PROCEDURE"` from the read → "reads only the owner's live
* procedures with a live document" goes red;
* - read the procedures without the `visible("profile")` check → "a grant
* without the visits section gets no procedure" goes red.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const mocks = vi.hoisted(() => ({
encounterFindMany: vi.fn(),
requireRecordAuth: vi.fn(),
visible: vi.fn((_domain: string) => true),
actingDomainVisibility: vi.fn(),
}));
vi.mock("@/lib/api-handler", () => ({
apiHandler: (handler: unknown) => handler,
requireRecordAuth: mocks.requireRecordAuth,
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: () => undefined,
}));
vi.mock("@/lib/db", () => ({
prisma: {
encounter: { findMany: mocks.encounterFindMany },
$queryRaw: vi.fn(async () => [{ used: BigInt(0) }]),
documentContentIndex: { count: vi.fn(async () => 0) },
inboundDocument: { count: vi.fn(async () => 0) },
},
}));
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
}));
vi.mock("@/lib/documents/upload-policy", () => ({
DOCUMENT_ACCEPTED_EXTENSIONS: [".pdf"],
resolveDocumentLimits: vi.fn(async () => ({
quotaBytes: 1000,
maxFileBytes: 100,
})),
}));
vi.mock("@/lib/documents/provider-order", () => ({
resolveDocumentAiCapability: vi.fn(async () => ({ available: false })),
}));
vi.mock("@/lib/links", () => ({
listDistinctTargets: vi.fn(async () => []),
listTargetsBySource: vi.fn(async () => new Map()),
replaceTargets: vi.fn(),
}));
vi.mock("@/lib/sharing/acting-domains", () => ({
actingDomainVisibility: mocks.actingDomainVisibility,
}));
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { GET } from "../route";
const handler = GET as unknown as () => Promise<Response>;
beforeEach(() => {
vi.clearAllMocks();
mocks.requireRecordAuth.mockResolvedValue({
user: { id: "owner-1" },
grantId: null,
});
mocks.visible.mockImplementation(() => true);
mocks.actingDomainVisibility.mockImplementation(async () => mocks.visible);
mocks.encounterFindMany.mockResolvedValue([
{
id: "enc-knee",
occurredAt: new Date("2025-10-02T08:00:00Z"),
reasonEncrypted: encryptToBytes("Knee arthroscopy"),
bodySiteEncrypted: encryptToBytes("Knee"),
laterality: "LEFT",
practitioner: null,
},
{
id: "enc-unnamed",
occurredAt: new Date("2024-05-10T08:00:00Z"),
reasonEncrypted: null,
bodySiteEncrypted: null,
laterality: null,
practitioner: { name: "Day clinic" },
},
]);
});
async function read() {
const res = await handler();
expect(res.status).toBe(200);
return (await res.json()).data;
}
describe("GET /api/documents/inbound/usage — linked procedures", () => {
it("publishes the linked procedures with the reason and site decrypted", async () => {
const data = await read();
expect(data.linkedProcedures).toEqual([
{
encounterId: "enc-knee",
occurredAt: "2025-10-02T08:00:00.000Z",
reason: "Knee arthroscopy",
bodySite: "Knee",
laterality: "LEFT",
practitionerName: null,
},
{
encounterId: "enc-unnamed",
occurredAt: "2024-05-10T08:00:00.000Z",
reason: null,
bodySite: null,
laterality: null,
practitionerName: "Day clinic",
},
]);
});
it("reads only the owner's live procedures with a live document, label columns only", async () => {
await read();
expect(mocks.encounterFindMany).toHaveBeenCalledTimes(1);
const arg = mocks.encounterFindMany.mock.calls[0]![0];
expect(arg.where).toEqual({
userId: "owner-1",
deletedAt: null,
kind: "PROCEDURE",
documentLinks: {
some: { userId: "owner-1", document: { deletedAt: null } },
},
});
// No outcome, no practitioner contact fields leave the database.
expect(Object.keys(arg.select).sort()).toEqual([
"bodySiteEncrypted",
"id",
"laterality",
"occurredAt",
"practitioner",
"reasonEncrypted",
]);
expect(arg.select.practitioner).toEqual({ select: { name: true } });
expect(arg.take).toBeGreaterThan(0);
});
it("reads the record owner's procedures for a delegate whose grant covers visits", async () => {
mocks.requireRecordAuth.mockResolvedValue({
user: { id: "owner-1" },
grantId: "grant-1",
});
const data = await read();
expect(mocks.actingDomainVisibility).toHaveBeenCalledWith(
expect.anything(),
"grant-1",
);
expect(mocks.visible).toHaveBeenCalledWith("profile");
expect(data.linkedProcedures).toHaveLength(2);
expect(mocks.encounterFindMany.mock.calls[0]![0].where.userId).toBe(
"owner-1",
);
});
it("a grant without the visits section gets no procedure, and nothing is read", async () => {
mocks.requireRecordAuth.mockResolvedValue({
user: { id: "owner-1" },
grantId: "grant-1",
});
mocks.visible.mockImplementation((domain) => domain !== "profile");
const data = await read();
expect(data.linkedProcedures).toEqual([]);
expect(mocks.encounterFindMany).not.toHaveBeenCalled();
// The positive control: the rest of the payload is still served.
expect(data.quotaBytes).toBe(1000);
expect(data.linkedEpisodes).toEqual([]);
});
it("reads one undecryptable reason as unnamed instead of failing the vault", async () => {
mocks.encounterFindMany.mockResolvedValue([
{
id: "enc-broken",
occurredAt: new Date("2025-01-01T00:00:00Z"),
reasonEncrypted: new Uint8Array([1, 2, 3, 4]),
bodySiteEncrypted: null,
laterality: null,
practitioner: null,
},
]);
const data = await read();
expect(data.linkedProcedures).toEqual([
expect.objectContaining({ encounterId: "enc-broken", reason: null }),
]);
});
});
+58 -33
View File
@@ -15,9 +15,11 @@ import {
resolveDocumentLimits,
} from "@/lib/documents/upload-policy";
import { resolveDocumentAiCapability } from "@/lib/documents/provider-order";
import { loadLinkedProcedures } from "@/lib/documents/links";
import { listDistinctTargets } from "@/lib/links";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
import type { DocumentUsageDto } from "@/lib/validations/inbound-documents";
export const dynamic = "force-dynamic";
@@ -32,53 +34,75 @@ export const dynamic = "force-dynamic";
* credential, endpoint or token crosses the wire, and every AI action it would
* gate stays refused under a switch.
*
* The procedure choices are the one part that reads past the documents
* section: their labels are the visit's own reason and body site, which belong
* to the visits section (`("read", "profile")`, the procedure history's own
* declaration). A grant that does not cover it gets an empty list, and the
* filter bar renders no procedure control — the same shape an owner with no
* filed procedure sees.
*
* No write arm exists on this route.
*/
export const GET = apiHandler(async () => {
const { user } = await requireRecordAuth("read", "documents");
const { user, grantId } = await requireRecordAuth("read", "documents");
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
if (!gate.enabled) return gate.response;
const [limits, rows, linkRows, capability, indexedCount, totalCount] =
await Promise.all([
resolveDocumentLimits(user.id),
prisma.$queryRaw<Array<{ used: bigint }>>`
const visible = await actingDomainVisibility(prisma, grantId);
const [
limits,
rows,
linkRows,
linkedProcedures,
capability,
indexedCount,
totalCount,
] = await Promise.all([
resolveDocumentLimits(user.id),
prisma.$queryRaw<Array<{ used: bigint }>>`
SELECT COALESCE(SUM(byte_size), 0)::bigint AS used
FROM inbound_documents
WHERE user_id = ${user.id}
`,
// Episodes that carry at least one LIVE document link — the filter
// bar's condition chips. Sourced here (not from the loaded corpus) so
// a chip exists even when its documents sit pages deep in the
// timeline; one indexed grouped query, no blobs.
listDistinctTargets(prisma, {
userId: user.id,
sourceKind: "document",
targetKind: "conditionEpisode",
}),
// Whether an AI action can run for this caller (assist + indexing share
// the same provider precondition). Honest availability so the UI never
// offers what the endpoint would refuse: the document provider order and
// the `documentAi` capability for this record, the same answer the
// capability probe gives.
resolveDocumentAiCapability(user.id),
// Content-index coverage: how many live documents are indexed …
// Scope the count to LIVE documents: a soft-deleted document keeps its
// index row (cascade fires on hard purge only), so an unscoped count
// would drift above `totalCount` and read the gauge past 100 %.
prisma.documentContentIndex.count({
where: { userId: user.id, document: { deletedAt: null } },
}),
// … out of how many live documents there are.
prisma.inboundDocument.count({
where: { userId: user.id, deletedAt: null },
}),
]);
// Episodes that carry at least one LIVE document link — the filter
// bar's condition chips. Sourced here (not from the loaded corpus) so
// a chip exists even when its documents sit pages deep in the
// timeline; one indexed grouped query, no blobs.
listDistinctTargets(prisma, {
userId: user.id,
sourceKind: "document",
targetKind: "conditionEpisode",
}),
// Procedures that carry at least one live document link — the filter
// bar's procedure choices, only where the visits section is readable.
visible("profile") ? loadLinkedProcedures(user.id) : Promise.resolve([]),
// Whether an AI action can run for this caller (assist + indexing share
// the same provider precondition). Honest availability so the UI never
// offers what the endpoint would refuse: the document provider order and
// the `documentAi` capability for this record, the same answer the
// capability probe gives.
resolveDocumentAiCapability(user.id),
// Content-index coverage: how many live documents are indexed …
// Scope the count to LIVE documents: a soft-deleted document keeps its
// index row (cascade fires on hard purge only), so an unscoped count
// would drift above `totalCount` and read the gauge past 100 %.
prisma.documentContentIndex.count({
where: { userId: user.id, document: { deletedAt: null } },
}),
// … out of how many live documents there are.
prisma.inboundDocument.count({
where: { userId: user.id, deletedAt: null },
}),
]);
const usedBytes = Number(rows[0]?.used ?? 0);
annotate({
action: { name: "documents.vault.usage" },
meta: { usedBytes, quotaBytes: limits.quotaBytes },
meta: {
usedBytes,
quotaBytes: limits.quotaBytes,
linkedProcedures: linkedProcedures.length,
},
});
const payload: DocumentUsageDto = {
@@ -90,6 +114,7 @@ export const GET = apiHandler(async () => {
episodeId: row.id,
name: row.label,
})),
linkedProcedures,
assistAvailable: capability.available,
contentIndex: {
enabled: capability.available,
@@ -26,6 +26,12 @@ vi.mock("@/lib/api-handler", () => ({
vi.mock("@/lib/ai/provider", () => ({
resolveProviderChain: vi.fn(),
probeProviderChain: vi.fn(async () => ({
entries: [],
localOcrEnabled: false,
managedBy: null,
availableTypes: ["codex", "openai", "admin-openai"],
})),
}));
vi.mock("@/lib/ai/provider-runner", () => ({
@@ -46,7 +52,7 @@ vi.mock("@/lib/db", () => ({
}));
import { GET } from "../route";
import { resolveProviderChain } from "@/lib/ai/provider";
import { probeProviderChain, resolveProviderChain } from "@/lib/ai/provider";
import { getLastWorkingProvider } from "@/lib/ai/provider-runner";
import { prisma } from "@/lib/db";
@@ -176,4 +182,37 @@ describe("GET /api/insights/provider-chain", () => {
true,
);
});
it("marks an entry without a credential as unavailable, enabled or not", async () => {
// The Coach's provider select offers only available entries; an entry
// marked available without a key was saved to the front of the chain
// and never answered.
vi.mocked(prisma.user.findUnique).mockResolvedValue({
aiProviderChain: [
{ providerType: "codex", priority: 1, enabled: true },
{ providerType: "anthropic", priority: 2, enabled: true },
{ providerType: "openai", priority: 3, enabled: false },
{ providerType: "local", priority: 4, enabled: true },
],
} as never);
vi.mocked(probeProviderChain).mockResolvedValueOnce({
entries: [{ providerType: "codex", vision: false }],
localOcrEnabled: false,
managedBy: "user",
availableTypes: ["codex", "openai"],
});
vi.mocked(resolveProviderChain).mockResolvedValue([
{ providerType: "codex", instance: { type: "codex" } as never },
]);
vi.mocked(getLastWorkingProvider).mockReturnValue(null);
const res = await (GET as () => Promise<Response>)();
const body = (await res.json()) as Envelope;
expect(body.data?.configuredChain).toEqual([
{ providerType: "codex", enabled: true, available: true },
{ providerType: "anthropic", enabled: true, available: false },
{ providerType: "openai", enabled: false, available: true },
{ providerType: "local", enabled: true, available: false },
]);
});
});
+12 -8
View File
@@ -7,7 +7,7 @@ import {
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { resolveProviderChain } from "@/lib/ai/provider";
import { probeProviderChain, resolveProviderChain } from "@/lib/ai/provider";
import {
forgetLastWorkingProvider,
getLastWorkingProvider,
@@ -26,11 +26,11 @@ import { providerChainPutSchema } from "@/lib/validations/ai-provider";
* in-process cache, when one is set. Surfaces "Active: codex
* (cached: openai)" so a user can see the runner has rerouted to
* a fallback without digging through logs.
* - `configuredChain`: ordered list of `{providerType, available}`
* entries. `available` is always true here since the chain
* resolver only returns instantiable entries; the field is wired
* for the v1.4.17 UX where unconfigured slots should still render
* in the list with a "needs setup" pill.
* - `configuredChain`: ordered list of `{providerType, enabled,
* available}` entries. `available` is the presence probe's own
* answer for that entry's credential (`probeProviderChain`), enabled
* or not: an entry without a key, address or sign-in is listed but
* cannot answer, so a picker must not offer it.
*
* v1.4.16 phase B2 added the matching `PUT` for the new dropdown-driven
* settings UX — the user reorders/toggles entries in the section and
@@ -58,7 +58,11 @@ export const GET = apiHandler(async () => {
});
const persisted = parseProviderChain(userRow?.aiProviderChain ?? null);
const resolved = await resolveProviderChain(user.id);
const [resolved, presence] = await Promise.all([
resolveProviderChain(user.id),
probeProviderChain(user.id),
]);
const availableTypes = new Set(presence.availableTypes ?? []);
const cached = getLastWorkingProvider(user.id);
annotate({
@@ -77,7 +81,7 @@ export const GET = apiHandler(async () => {
configuredChain: persisted.map((entry) => ({
providerType: entry.providerType,
enabled: entry.enabled,
available: true,
available: availableTypes.has(entry.providerType),
})),
});
});
+2 -54
View File
@@ -24,8 +24,8 @@ import {
useCoachConversationHistory,
useDeleteCoachConversationWithUndo,
} from "@/components/insights/coach-panel/use-coach";
import type { CoachConversationDTO } from "@/lib/ai/coach/types";
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
import { groupConversationsByRecency } from "@/lib/insights/coach-conversation-groups";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useAiCapabilityAnswer } from "@/hooks/use-ai-capability";
import { useLoadMoreSentinel } from "@/hooks/use-load-more-sentinel";
@@ -58,58 +58,6 @@ import { cn } from "@/lib/utils";
* wrapper matches the Coach page so the two surfaces share one chrome.
*/
type RecencyGroupId = "today" | "yesterday" | "thisWeek" | "earlier";
interface RecencyGroup {
id: RecencyGroupId;
labelKey: string;
conversations: CoachConversationDTO[];
}
/**
* Bucket conversations by LOCAL calendar recency. `updatedAt` is an ISO
* string; comparisons run against local day boundaries so "Today" tracks
* the user's own midnight, not UTC.
*/
function groupByRecency(conversations: CoachConversationDTO[]): RecencyGroup[] {
const now = new Date();
const startOfToday = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
).getTime();
const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000;
// "This week" = the six days before yesterday (a rolling 7-day window
// that already excludes today + yesterday, which have their own groups).
const startOfWeek = startOfToday - 6 * 24 * 60 * 60 * 1000;
const groups: Record<RecencyGroupId, CoachConversationDTO[]> = {
today: [],
yesterday: [],
thisWeek: [],
earlier: [],
};
for (const conversation of conversations) {
const updated = new Date(conversation.updatedAt).getTime();
if (updated >= startOfToday) groups.today.push(conversation);
else if (updated >= startOfYesterday) groups.yesterday.push(conversation);
else if (updated >= startOfWeek) groups.thisWeek.push(conversation);
else groups.earlier.push(conversation);
}
return (
[
{ id: "today", labelKey: "insights.coach.history.groupToday" },
{ id: "yesterday", labelKey: "insights.coach.history.groupYesterday" },
{ id: "thisWeek", labelKey: "insights.coach.history.groupThisWeek" },
{ id: "earlier", labelKey: "insights.coach.history.groupEarlier" },
] as const
)
.map((g) => ({ ...g, conversations: groups[g.id] }))
.filter((g) => g.conversations.length > 0);
}
function CoachConversationsBody({ readOnly }: { readOnly: boolean }) {
const { t, locale } = useTranslations();
const [openId, setOpenId] = useState<string | null>(null);
@@ -139,7 +87,7 @@ function CoachConversationsBody({ readOnly }: { readOnly: boolean }) {
);
const isSearching = debouncedFilter.trim().length > 0;
const groups = useMemo(() => groupByRecency(visible), [visible]);
const groups = useMemo(() => groupConversationsByRecency(visible), [visible]);
// Same callback-ref pattern as the rail: a `useState` setter (not a plain
// `useRef`) so the sentinel hook's `root` re-fires its effect once the
+23
View File
@@ -81,6 +81,13 @@ import { coachScopeSourceSchema } from "@/lib/ai/coach/types";
* this is a prefill, not an auto-send.
* Both are ignored once `?c=` or `?doc=` pin an existing/scoped thread — scope
* only ever applies to a fresh conversation's first turn.
*
* The conversations panel on the right of the thread carries the history,
* New chat and the settings gear (the composer's `+` menu is gone). Picking a
* conversation or starting a new one rewrites `?c=` in place, so a reload
* stays on the open thread. `?settings=data` (the link from Settings → Coach)
* opens the gear on "What I can see"; the param is consumed on mount and
* dropped from the URL so a reload does not reopen the overlay.
*/
function CoachPageBody() {
const searchParams = useSearchParams();
@@ -118,6 +125,21 @@ function CoachPageBody() {
: null;
const seedPrefill = freshChat ? searchParams.get("ask") : null;
// Read once: the panel consumes it on mount, then the URL loses it.
const [openSettingsOnData] = useState(
() => searchParams.get("settings") === "data",
);
useEffect(() => {
if (!openSettingsOnData) return;
const url = new URL(window.location.href);
url.searchParams.delete("settings");
window.history.replaceState(
window.history.state,
"",
url.pathname + url.search,
);
}, [openSettingsOnData]);
// #781 — a navigation away mid-reply recorded the interrupted conversation
// (see `COACH_INTERRUPTED_STORAGE_KEY` in `use-coach.ts`). Returning to the
// Coach reopens that thread — where the persisted `cancelled` marker shows
@@ -200,6 +222,7 @@ function CoachPageBody() {
// unread coach-initiated exception and the interrupted first-turn
// resume (#781, whose conversation id never reached the client).
autoOpenMostRecent={hasUnreadCoachMessage || resumeInterruptedLatest}
openSettingsOnData={openSettingsOnData}
/>
);
}
+17
View File
@@ -1054,3 +1054,20 @@
scroll-behavior: auto !important;
}
}
/* ── Toasts clear the Coach's New chat button ────────────────────────
* The Coach's conversations panel keeps its round New chat button in the
* bottom-right corner, which is where every toast lands. While the panel is
* open (docked or as a sheet), bottom toasts rise by the button's footprint
* (48 px plus its 16 px inset) so a delete's Undo never covers it. The
* selectors outrank sonner's own `bottom` rules, phone width included. */
:root:has([data-slot="coach-conversations-panel"][data-state="open"])
[data-sonner-toaster][data-y-position="bottom"] {
bottom: calc(var(--offset-bottom) + 4rem);
}
@media (max-width: 600px) {
:root:has([data-slot="coach-conversations-panel"][data-state="open"])
[data-sonner-toaster][data-y-position="bottom"] {
bottom: calc(var(--mobile-offset-bottom) + 4rem);
}
}
@@ -355,6 +355,9 @@ const DIGEST: DailyDigest = {
score: null,
topSignal: null,
briefingLead: "A steady week so far.",
lead: { text: "A steady week so far.", source: "briefing" },
today: [],
restMode: null,
line: "A steady week so far.",
justIn: null,
reactionLine: null,
@@ -42,6 +42,9 @@ function digest(over: Partial<DailyDigest> = {}): DailyDigest {
score: { value: 82, band: "green", delta: 3 },
topSignal: null,
briefingLead: "Your week is trending steady.",
lead: { text: "Your week is trending steady.", source: "briefing" },
today: [],
restMode: null,
line: "Your week is trending steady.",
worthALook: [],
justIn: null,
@@ -105,6 +108,7 @@ describe("TodayHero — an arrival is not a chip", () => {
digest={digest({
score: null,
briefingLead: null,
lead: null,
worthALook: [],
reactionLine: null,
justIn: { kind: "sleep_night", at: ARRIVED_AT },
@@ -152,6 +156,7 @@ describe("TodayHero — what earns the hero without a score", () => {
return digest({
score: null,
briefingLead: null,
lead: null,
reactionLine: null,
phase: "provisional",
sleepPending: true,
@@ -203,6 +208,8 @@ describe("TodayHero — the reaction line replaces the lead", () => {
digest={digest({
reactionLine: REACTION,
briefingLead: "Your week is trending steady.",
// The server resolved the reaction line as the lead.
lead: { text: REACTION, source: "reaction" },
})}
/>,
);
@@ -218,6 +225,8 @@ describe("TodayHero — the reaction line replaces the lead", () => {
digest={digest({
reactionLine: REACTION,
briefingLead: "Your week is trending steady.",
// The server resolved the reaction line as the lead.
lead: { text: REACTION, source: "reaction" },
})}
/>,
);
@@ -246,6 +255,7 @@ describe("TodayHero — the reaction line replaces the lead", () => {
digest={digest({
score: null,
briefingLead: null,
lead: { text: REACTION, source: "reaction" },
worthALook: [],
reactionLine: REACTION,
justIn: { kind: "weight", at: ARRIVED_AT },
@@ -6,6 +6,7 @@ import { I18nProvider } from "@/lib/i18n/context";
import { TodayHero } from "../today-hero";
import type { DailyDigest } from "@/lib/daily/digest";
import type { PriorityItem } from "@/lib/daily/priority-item";
import type { TodayFact } from "@/lib/daily/today-overview";
import { SCORE_VERSION } from "@/lib/analytics/score/types";
import { DIGEST_AI_AVAILABLE } from "@/__tests__/helpers/ai-capability-fixtures";
@@ -71,6 +72,9 @@ function digest(over: Partial<DailyDigest> = {}): DailyDigest {
delta: "+6 mmHg vs your 30-day average",
},
briefingLead: "Your week is trending steady.",
lead: { text: "Your week is trending steady.", source: "briefing" },
today: [],
restMode: null,
line: "Your week is trending steady.",
worthALook: [doseItem, syncItem],
justIn: null,
@@ -146,6 +150,7 @@ describe("<TodayHero>", () => {
sleepPending: true,
score: null,
briefingLead: null,
lead: null,
line: "Your health score today is 82.",
worthALook: [doseItem],
})}
@@ -160,13 +165,15 @@ describe("<TodayHero>", () => {
expect(html).not.toContain('data-slot="today-hero-briefing-link"');
});
it("keeps the useful loaded narrative without repeating the ring's numeric score", () => {
it("renders the server-resolved lead without repeating the ring's numeric score", () => {
// The server already dropped the score sentence from the briefing (see
// `today-overview.test.ts`); the hero prints what it was handed.
const html = render(
<TodayHero
digest={digest({
briefingLead:
"Your health score is 82. Your week is trending steady.",
line: "Your health score is 82. Your week is trending steady.",
briefingLead: "Your week is trending steady.",
lead: { text: "Your week is trending steady.", source: "briefing" },
line: "Your week is trending steady.",
})}
/>,
);
@@ -178,12 +185,47 @@ describe("<TodayHero>", () => {
expect(html).toContain('data-slot="today-hero-lead"');
});
it("promotes the top signal when score de-duplication removes the only lead", () => {
it("falls back to the stored lines for a digest cached before the lead existed", () => {
// The service worker's offline cache can hand back a digest written by
// the previous version, which carries no `lead` field at all.
const cached = digest({ reactionLine: "The new reading fits your week." });
delete (cached as Partial<DailyDigest>).lead;
const html = render(<TodayHero digest={cached} />);
expect(visibleText(html)).toContain("The new reading fits your week.");
const briefingOnly = digest();
delete (briefingOnly as Partial<DailyDigest>).lead;
expect(visibleText(render(<TodayHero digest={briefingOnly} />))).toContain(
"Your week is trending steady.",
);
});
it("does not resurrect a line the server left out of the lead", () => {
// A current digest with `lead: null` decided there is nothing to say;
// its reaction line (e.g. one that only repeated the score) stays out.
const html = render(
<TodayHero
digest={digest({
briefingLead: "Your health score is 82.",
line: "Your health score is 82.",
lead: null,
briefingLead: null,
topSignal: null,
reactionLine: "Score 82 today.",
})}
/>,
);
expect(visibleText(html)).not.toContain("Score 82 today.");
});
it("does not repeat the top signal under a lead made from it", () => {
// A briefing whose only sentence repeated the score leads with the top
// signal's headline (resolved on the server); the muted signal line under
// it would say the same thing twice.
const html = render(
<TodayHero
digest={digest({
briefingLead: null,
lead: { text: "Pulse is settling lately", source: "briefing" },
line: "Pulse is settling lately",
topSignal: {
sourceMetric: "pulse",
tone: "info",
@@ -208,6 +250,7 @@ describe("<TodayHero>", () => {
sleepPending: true,
score: null,
briefingLead: null,
lead: null,
reactionLine: null,
line: "Your health score today is 82.",
worthALook: [doseItem],
@@ -228,6 +271,7 @@ describe("<TodayHero>", () => {
score: null,
topSignal: null,
briefingLead: null,
lead: null,
line: "Nothing needs your attention today — everything's tracking normally.",
worthALook: [],
})}
@@ -246,6 +290,7 @@ describe("<TodayHero>", () => {
score: null,
topSignal: null,
briefingLead: null,
lead: null,
line: "Nothing needs your attention today — everything's tracking normally.",
worthALook: [],
reactionLine: null,
@@ -273,7 +318,7 @@ describe("<TodayHero>", () => {
expect(html).toContain('data-slot="today-hero-score"');
});
it("shows the first-class all-clear line when a score is present but nothing is notable", () => {
it("draws no empty rail and no all-clear sentence once a lead carries the day", () => {
const html = render(
<TodayHero
digest={digest({
@@ -282,16 +327,36 @@ describe("<TodayHero>", () => {
/>,
);
expect(html).toContain('data-slot="today-hero"');
expect(html).toContain('data-slot="today-hero-all-clear"');
expect(html).toContain('data-slot="today-hero-lead"');
expect(html).not.toContain('data-slot="today-hero-all-clear"');
expect(html).not.toContain('data-slot="today-hero-rail"');
});
it("keeps only the delta when the lead already says the headline", () => {
const html = render(
<TodayHero
digest={digest({
lead: {
text: "Blood pressure a touch high this morning.",
source: "briefing",
},
})}
/>,
);
const text = visibleText(html);
expect(html).toContain('data-slot="today-hero-signal"');
expect(text).toContain("+6 mmHg vs your 30-day average");
// Said once, by the lead; the signal line carries only the delta.
expect(text.split("touch high this morning").length - 1).toBe(1);
});
it("uses the compact score-only composition when all-clear has no narrative", () => {
const html = render(
<TodayHero
digest={digest({
topSignal: null,
briefingLead: null,
lead: null,
reactionLine: null,
line: "Your health score today is 82.",
worthALook: [],
@@ -379,6 +444,7 @@ describe("<TodayHero>", () => {
score: null,
topSignal: null,
briefingLead: null,
lead: null,
worthALook: [],
})}
primaryContent="reminders"
@@ -446,3 +512,172 @@ describe("<TodayHero> score basis", () => {
expect(html).not.toContain('data-slot="today-hero-score-basis"');
});
});
const FACTS: TodayFact[] = [
{
kind: "rest_mode",
label: "Rest mode",
value: "Day 3",
href: "/illness",
moduleKey: "illness",
},
{
kind: "medications",
label: "Medications",
value: "1 of 3 taken",
href: "/medications",
moduleKey: "medications",
},
{
kind: "appointment",
label: "Appointment",
value: "Tomorrow 09:30, Dr. Example",
href: "/checkups",
},
{
kind: "sleep",
label: "Last night",
value: "7h 20m, close to your usual",
href: "/insights/sleep",
moduleKey: "sleep",
},
{
kind: "cycle",
label: "Cycle",
value: "Follicular, day 12",
href: "/cycle",
moduleKey: "cycle",
},
];
describe("<TodayHero> Today overview", () => {
it("renders each fact as a link to its page, in the order it was given", () => {
const html = render(<TodayHero digest={digest({ today: FACTS })} />);
expect(html).toContain('data-slot="today-hero-today"');
const kinds = [
...html.matchAll(/data-slot="today-hero-fact" data-kind="([a-z_]+)"/g),
].map((m) => m[1]);
expect(kinds).toEqual([
"rest_mode",
"medications",
"appointment",
"sleep",
"cycle",
]);
for (const fact of FACTS) {
expect(html).toContain(`href="${fact.href}"`);
expect(visibleText(html)).toContain(fact.value);
}
});
it("hides the fifth fact below md and never the first four", () => {
const html = render(<TodayHero digest={digest({ today: FACTS })} />);
const items = [
...html.matchAll(/<li[^>]*data-slot="today-hero-fact"[^>]*>/g),
].map((m) => m[0]);
expect(items).toHaveLength(5);
items.slice(0, 4).forEach((li) => expect(li).not.toContain("hidden"));
expect(items[4]).toContain("hidden md:block");
});
it("drops the all-clear sentence when facts carry the day", () => {
const html = render(
<TodayHero
digest={digest({ today: FACTS.slice(0, 2), worthALook: [] })}
/>,
);
expect(html).not.toContain('data-slot="today-hero-all-clear"');
expect(html).toContain('data-layout="narrative"');
});
it("keeps the hero for an account whose only content is facts", () => {
const html = render(
<TodayHero
digest={digest({
score: null,
topSignal: null,
briefingLead: null,
lead: null,
worthALook: [],
today: FACTS.slice(1, 2),
})}
/>,
);
expect(html).toContain('data-slot="today-hero"');
expect(html).toContain('data-slot="today-hero-today"');
});
it("renders a deterministic lead without the AI signal line", () => {
const html = render(
<TodayHero
digest={digest({
lead: {
text: "All 4 of your latest vitals sit inside their usual range.",
source: "signal",
},
})}
/>,
);
expect(html).toContain('data-source="signal"');
expect(html).not.toContain('data-slot="today-hero-signal"');
});
it("shows the facts in reminders mode, without the ring", () => {
const html = render(
<TodayHero
digest={digest({ today: FACTS.slice(0, 2) })}
primaryContent="reminders"
/>,
);
expect(html).toContain('data-slot="today-hero-today"');
expect(html).not.toContain('data-slot="today-hero-score"');
});
});
describe("<TodayHero> steady line", () => {
const steady = (over: Partial<NonNullable<DailyDigest["score"]>>) =>
digest({
score: {
value: 94,
band: "green",
delta: null,
deltaReason: "below_noise_floor",
scoreVersion: SCORE_VERSION,
composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"],
steadyWeeks: 4,
...over,
},
});
it("says how long the score has held when no delta is shown", () => {
const html = render(<TodayHero digest={steady({})} />);
expect(html).toContain('data-slot="today-hero-score-steady"');
expect(visibleText(html)).toContain("Steady for 4 weeks");
expect(html).not.toContain('data-slot="today-hero-score-delta"');
});
it("never pairs a steady line with a delta", () => {
const html = render(
<TodayHero digest={steady({ delta: 3, deltaReason: null })} />,
);
expect(html).toContain('data-slot="today-hero-score-delta"');
expect(html).not.toContain('data-slot="today-hero-score-steady"');
});
it("says nothing without a run", () => {
const html = render(<TodayHero digest={steady({ steadyWeeks: null })} />);
expect(html).not.toContain('data-slot="today-hero-score-steady"');
});
it("says at least when the run reaches past what was read", () => {
const html = render(
<TodayHero digest={steady({ steadyWeeks: 17, steadyAtLeast: true })} />,
);
expect(visibleText(html)).toContain("Steady for at least 17 weeks");
});
it("uses the German plural", () => {
const html = render(<TodayHero digest={steady({})} />, "de");
expect(visibleText(html)).toContain("Seit 4 Wochen stabil");
});
});
+165 -91
View File
@@ -54,6 +54,8 @@ import { usePriorityItemDismiss } from "@/hooks/use-priority-item-dismiss";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { pluralKey } from "@/lib/i18n/plural";
import { MAX_TODAY_FACTS_NARROW as TODAY_FACTS_NARROW } from "@/lib/daily/today-overview";
import type { DailyDigest } from "@/lib/daily/digest";
import type { HeroPrimaryContent } from "@/lib/dashboard-layout";
import {
@@ -72,26 +74,18 @@ function formatDelta(
return t("daily.today.deltaVsBaseline", { delta: signed });
}
/**
* The ring already owns the headline number. AI briefing copy can still carry
* an older lead sentence that repeats that exact score, so remove only the
* sentence containing the displayed number and retain every other useful
* sentence verbatim. If the score sentence was the whole lead, render no lead
* rather than inventing replacement prose.
*/
function withoutRepeatedScore(
text: string | null,
score: number | null,
): string | null {
if (!text || score === null) return text;
const shownScore = String(Math.round(score));
const scorePattern = new RegExp(
`(^|[^0-9])${shownScore.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&")}([^0-9]|$)`,
);
const sentences = text.match(/[^.!?]+[.!?]+(?:\s+|$)|[^.!?]+$/g) ?? [text];
const retained = sentences.filter((sentence) => !scorePattern.test(sentence));
const result = retained.join(" ").replace(/\s+/g, " ").trim();
return result.length > 0 ? result : null;
/** Lowercase letters and digits only, for a wording-insensitive compare. */
function normalise(text: string): string {
return text
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, " ")
.trim();
}
/** Whether `lead` already says what `headline` says. */
function restates(lead: string, headline: string): boolean {
const h = normalise(headline);
return h.length > 0 && normalise(lead).includes(h);
}
export function TodayHero({
@@ -104,7 +98,7 @@ export function TodayHero({
/** Server-persisted hero choice from the dashboard layout blob. */
primaryContent?: HeroPrimaryContent;
}) {
const { t } = useTranslations();
const { t, locale } = useTranslations();
// The hero is deliberately NOT mount-gated (v1.30.9: it is the LCP element
// and paints from the server-dehydrated digest on both the SSR and the
// hydration render), so nothing in it may differ between those two passes.
@@ -138,35 +132,61 @@ export function TodayHero({
const hasScore = digest.score !== null;
const hasItems = digest.worthALook.length > 0;
// Lead precedence, warmest first: the day's reaction line REPLACES the
// briefing lead once something landed — it never appends as a second
// paragraph, so the hero stays exactly one lead line tall and nothing below
// it shifts. The briefing lead is the standing read; the deterministic
// `line` is the floor a keyless self-hoster still gets.
const rawLead =
digest.reactionLine ??
digest.briefingLead ??
// The deterministic line is a useful floor only once the ring has a real
// score. A provisional/null ring must not pair with a fabricated score
// sentence from that fallback.
(hasScore ? digest.line : null);
const lead = withoutRepeatedScore(rawLead, digest.score?.value ?? null);
// `?? []` / `?? null` below: the service worker's offline data cache can
// still hand back a digest written before the overview fields existed, and
// a missing list must read as "no facts", not as a crash.
const facts = digest.today ?? [];
const hasFacts = facts.length > 0;
// The lead is resolved on the server (`digest.lead`): the reaction line or
// a briefing sentence with content while AI text is available, otherwise a
// deterministic sentence about the day's strongest signal. Greetings and a
// sentence that only repeats the ring's number never reach it, so the hero
// renders it verbatim and decides nothing about it. A digest the service
// worker cached before the field existed has no `lead` at all; it falls
// back to the lines that version led with.
const lead =
digest.lead === undefined
? (digest.reactionLine ?? digest.briefingLead ?? null)
: (digest.lead?.text ?? null);
const topSignal = digest.topSignal;
// A score-only briefing can legitimately lose its lead when the only
// sentence repeated the number already printed inside the ring. Keep the
// day's signal as the visible headline in that case instead of leaving the
// hero with a muted, orphaned subtitle below an empty bold slot.
const signalFallbackLead =
hasScore && !lead ? topSignal?.headline?.trim() || null : null;
const displayLead = lead ?? signalFallbackLead;
// A score-only all-clear digest has no narrative content for the leading
// column. Keeping the full md ring in that two-column shell left a blank
// column beside a 168 px dial and pushed the actual all-clear read below it.
// Treat that honest fallback as its own compact composition: the all-clear
// copy occupies the leading column and a smaller version of the SAME score
// ring remains the one numeric face. This branch depends only on the
// server-delivered digest, so SSR and hydration choose it identically.
const compactAllClear = !lead && !topSignal && !hasItems;
// The briefing's top signal rides under an AI lead as its supporting line,
// and only with what the lead does not already say: a headline the lead
// restates is dropped and its delta, when there is one, stands alone.
const signalLine =
topSignal && lead && digest.lead?.source !== "signal"
? restates(lead, topSignal.headline)
? topSignal.delta
? { headline: null, delta: topSignal.delta }
: null
: { headline: topSignal.headline, delta: topSignal.delta }
: null;
// A score-only digest with nothing else to say has no narrative content for
// the leading column. Keeping the full md ring in that two-column shell left
// a blank column beside a 168 px dial and pushed the all-clear read below
// it, so that case keeps its compact composition: the all-clear copy leads
// and a smaller version of the SAME score ring stays the one numeric face.
// This branch depends only on the server-delivered digest, so SSR and
// hydration choose it identically.
const compactAllClear = !lead && !signalLine && !hasItems && !hasFacts;
// How long the number has held, read off the stored daily scores on the
// server. Said only when no delta is: a moved score is described by its
// delta, a still one by its duration, never both under one ring.
const deltaShown =
digest.score !== null &&
digest.score.delta !== null &&
digest.score.deltaReason === null;
const steadyWeeks = digest.score?.steadyWeeks ?? null;
const steadyLine =
!deltaShown && steadyWeeks !== null
? t(
// A run that reaches past what was read has no known start.
digest.score?.steadyAtLeast
? pluralKey("daily.today.steadyAtLeastWeeks", steadyWeeks, locale)
: pluralKey("daily.today.steadyWeeks", steadyWeeks, locale),
{ count: steadyWeeks },
)
: null;
// v1.38 — what the ring's number rests on, when that is less than the
// breadth the score recommends. The hero renders the number and nothing
@@ -203,13 +223,7 @@ export function TodayHero({
//
// A filtered layout is the one explicit exception: its empty digest means
// "the hidden canonical candidates are quiet here", so retain all-clear.
if (
!renderFilteredAllClear &&
!hasScore &&
!hasItems &&
!digest.briefingLead &&
!digest.reactionLine
) {
if (!renderFilteredAllClear && !hasScore && !hasItems && !hasFacts && !lead) {
return null;
}
@@ -252,6 +266,45 @@ export function TodayHero({
</div>
) : null;
/* The Today overview — up to five statements about the day, resolved on the
server in priority order. A phone shows the first four: the fifth is
hidden below `md` rather than dropped, so the list a wide screen reads is
the same list, one line longer. Each line opens the page it belongs to.
The block never asks for anything; the rail below is the one place on the
hero that may. */
const todayBlock = hasFacts ? (
<div className="space-y-2" data-slot="today-hero-today">
<h2 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t("daily.today.heading")}
</h2>
<ul className="flex flex-col md:grid md:grid-cols-2 md:gap-x-6 md:gap-y-3 xl:grid-cols-3">
{facts.map((fact, i) => (
<li
key={fact.kind}
data-slot="today-hero-fact"
data-kind={fact.kind}
className={cn(
"min-w-0",
i >= TODAY_FACTS_NARROW && "hidden md:block",
)}
>
<Link
href={fact.href}
className="focus-visible:ring-ring/50 hover:bg-muted/50 -mx-1.5 flex min-h-11 items-center justify-between gap-3 rounded-md px-1.5 py-1 focus-visible:ring-2 focus-visible:outline-none md:min-h-0 md:flex-col md:items-start md:justify-start md:gap-0.5"
>
<span className="text-muted-foreground shrink-0 text-sm md:text-xs">
{fact.label}
</span>
<span className="text-foreground min-w-0 text-right text-sm font-medium md:text-left">
{fact.value}
</span>
</Link>
</li>
))}
</ul>
</div>
) : null;
const heroShellClassName = cn(
// The tile strip's surface plus the ONE sanctioned Today atmosphere:
// `.today-hero-wash` leans a faint `--primary` mix over the theme
@@ -275,14 +328,16 @@ export function TodayHero({
className={heroShellClassName}
>
<div className="flex flex-col gap-3 md:gap-4">
{rail ?? (
<p
data-slot="today-hero-all-clear"
className="text-muted-foreground text-sm"
>
{t("daily.today.allClear")}
</p>
)}
{todayBlock}
{rail ??
(hasFacts ? null : (
<p
data-slot="today-hero-all-clear"
className="text-muted-foreground text-sm"
>
{t("daily.today.allClear")}
</p>
))}
{sleepPendingNote}
</div>
</section>
@@ -312,33 +367,43 @@ export function TodayHero({
: "flex-col md:flex-row md:items-start md:justify-between md:gap-6",
)}
>
<div className="min-w-0 flex-1 space-y-2">
{/* The overview is its own section under the lead, so it keeps a
section's distance from it; the compact all-clear stays tight. */}
<div
className={cn(
"min-w-0 flex-1",
compactAllClear ? "space-y-3" : "space-y-4 md:space-y-6",
)}
>
{/* Hero numeric face: the read leads large in the foreground
token, calm and legible — the day's read, not a slogan. */}
{displayLead ? (
<div
data-slot="today-hero-lead"
className="text-foreground text-lg leading-snug font-semibold tracking-tight sm:text-xl"
>
<ProseBlocks text={displayLead} strip linkify={false} />
{lead || signalLine ? (
<div className="space-y-2">
{lead ? (
<div
data-slot="today-hero-lead"
data-source={digest.lead?.source}
className="text-foreground text-lg leading-snug font-semibold tracking-tight sm:text-xl"
>
<ProseBlocks text={lead} strip linkify={false} />
</div>
) : null}
{/* Top signal — present-tense headline + optional delta, one
muted step down so it supports the lead without
competing. */}
{signalLine ? (
<p
data-slot="today-hero-signal"
className="text-muted-foreground text-sm"
>
{signalLine.headline}
{signalLine.headline && signalLine.delta ? " · " : null}
{signalLine.delta}
</p>
) : null}
</div>
) : null}
{/* Top signal — present-tense headline + optional delta, one
muted step down so it supports the lead without competing. */}
{topSignal && lead ? (
<p
data-slot="today-hero-signal"
className="text-muted-foreground text-sm"
>
{topSignal.headline}
{topSignal.delta ? (
<span className="text-muted-foreground">
{" "}
· {topSignal.delta}
</span>
) : null}
</p>
) : null}
{todayBlock}
{compactAllClear ? (
<p
data-slot="today-hero-all-clear"
@@ -380,9 +445,7 @@ export function TodayHero({
/>
</Link>
</div>
{digest.score &&
digest.score.delta !== null &&
digest.score.deltaReason === null ? (
{deltaShown && digest.score?.delta != null ? (
<span
data-slot="today-hero-score-delta"
className="text-muted-foreground text-xs tabular-nums"
@@ -390,6 +453,14 @@ export function TodayHero({
{formatDelta(digest.score.delta, t)}
</span>
) : null}
{steadyLine ? (
<span
data-slot="today-hero-score-steady"
className="text-muted-foreground text-xs"
>
{steadyLine}
</span>
) : null}
{basisLine ? (
<span
data-slot="today-hero-score-basis"
@@ -411,8 +482,11 @@ export function TodayHero({
{/* Worth-a-look rail — S1's `PriorityCard`s, bounded 0–3. When the
digest is all-clear, a first-class muted line stands in for the
rail (calm inversion of an alarm), never an empty card. */}
{/* The all-clear sentence only ever stands in for an empty card
(the compact composition above). Once a lead or facts carry the
day, an empty rail simply is not drawn. */}
{rail ??
(!compactAllClear ? (
(!compactAllClear && !hasFacts && !lead ? (
<p
data-slot="today-hero-all-clear"
className="text-muted-foreground text-sm"
@@ -0,0 +1,122 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
* The vault filter rail's procedure facet, pinned via single-pass static
* renders (the project's component-test convention; the open-and-pick path is
* the vault e2e). The facet mirrors the condition facet beside it: absent when
* nothing is filed against a procedure, an icon-only trigger on a phone while
* inactive, the procedure's own heading once one is picked, placed between the
* condition and the year, and counted by the one clear control.
*
* Mutation checks (each run, each seen red):
* - render the trigger with no choices → "renders no procedure control"
* goes red;
* - drop the active tint → "shows the picked procedure" goes red.
*/
import { I18nProvider } from "@/lib/i18n/context";
import { DocumentFilterBar } from "../document-filter-bar";
import type { ProcedureFilterChoice } from "../vault-utils";
const KNEE: ProcedureFilterChoice = {
encounterId: "enc-knee",
name: "Knee arthroscopy",
detail: "Knee (left) · 2 Oct 2025",
};
function render({
procedures = [KNEE],
activeEncounterId,
activeCount = 0,
}: {
procedures?: ProcedureFilterChoice[];
activeEncounterId?: string;
activeCount?: number;
} = {}) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<DocumentFilterBar
searchValue=""
onSearchChange={() => {}}
searchInputRef={{ current: null }}
activeKinds={new Set()}
onToggleKind={() => {}}
conditionChips={[{ episodeId: "ep-1", name: "Knee" }]}
activeEpisodeId={undefined}
onToggleEpisode={() => {}}
procedureChips={procedures}
activeEncounterId={activeEncounterId}
onToggleEncounter={() => {}}
years={[2025]}
activeYear={undefined}
onToggleYear={() => {}}
activeCount={activeCount}
onClearAll={() => {}}
/>
</I18nProvider>,
);
}
/** The opening tag of the element carrying `data-slot="<slot>"`. */
function triggerTag(html: string, slot: string): string {
const at = html.indexOf(`data-slot="${slot}"`);
expect(at).toBeGreaterThan(-1);
return html.slice(html.lastIndexOf("<", at), html.indexOf(">", at) + 1);
}
describe("<DocumentFilterBar> procedure facet", () => {
it("renders no procedure control when nothing is filed against a procedure", () => {
const html = render({ procedures: [] });
expect(html).not.toContain('data-slot="document-procedure-filter"');
// The positive control: the neighbouring facets still render.
expect(html).toContain('data-slot="document-condition-filter"');
expect(html).toContain('data-slot="document-year-filter"');
});
it("sits between the condition and the year facet on the one row", () => {
const html = render();
const condition = html.indexOf('data-slot="document-condition-filter"');
const procedure = html.indexOf('data-slot="document-procedure-filter"');
const year = html.indexOf('data-slot="document-year-filter"');
expect(condition).toBeGreaterThan(-1);
expect(procedure).toBeGreaterThan(condition);
expect(year).toBeGreaterThan(procedure);
expect(html).toContain("flex flex-nowrap items-center gap-2");
});
it("is an icon-only trigger on a phone while inactive, named for the facet", () => {
const html = render();
const tag = triggerTag(html, "document-procedure-filter");
expect(tag).toContain('aria-label="All procedures"');
expect(tag).not.toContain("border-primary/40");
// Same trigger chrome as the condition facet: the label hides below `sm`.
const conditionTag = triggerTag(html, "document-condition-filter");
expect(tag.match(/class="([^"]*)"/)?.[1]).toBe(
conditionTag.match(/class="([^"]*)"/)?.[1],
);
expect(html).toContain(
'<span class="max-w-28 min-w-0 truncate hidden sm:inline">All procedures</span>',
);
});
it("shows the picked procedure's heading, tinted, and visible on a phone", () => {
const html = render({ activeEncounterId: "enc-knee", activeCount: 1 });
const tag = triggerTag(html, "document-procedure-filter");
expect(tag).toContain('aria-label="Knee arthroscopy"');
expect(tag).toContain("border-primary/40 bg-primary/10 text-foreground");
expect(html).toContain(
'<span class="max-w-28 min-w-0 truncate inline">Knee arthroscopy</span>',
);
// The one clear control appears for it like for any other facet.
expect(html).toContain('aria-label="Clear filters"');
});
it("leaves the trigger at rest for a filtered visit that is not a choice", () => {
// A routine visit reached from its own row filters the list but is not a
// procedure: the facet must not claim it.
const html = render({ activeEncounterId: "enc-routine", activeCount: 1 });
const tag = triggerTag(html, "document-procedure-filter");
expect(tag).toContain('aria-label="All procedures"');
expect(tag).not.toContain("border-primary/40");
});
});
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
import {
buildProcedureChoices,
buildTimelineItems,
buildVaultListApiSearch,
classifyUploadFailure,
@@ -457,3 +458,63 @@ describe("documentCardKeyAction", () => {
}
});
});
describe("buildProcedureChoices", () => {
const labels = {
kindName: "Procedure or surgery",
siteText: (site: string | null, side: string | null) =>
site ? (side ? `${site} (${side.toLowerCase()})` : site) : null,
formatDate: (iso: string) => iso.slice(0, 10),
};
const knee = {
encounterId: "enc-knee",
occurredAt: "2025-10-02T08:00:00.000Z",
reason: "Knee arthroscopy",
bodySite: "Knee",
laterality: "LEFT" as const,
practitionerName: "Day clinic",
};
it("heads each choice like the visits page: reason, else practice, else kind", () => {
const choices = buildProcedureChoices(
[
knee,
{ ...knee, encounterId: "enc-2", reason: null, bodySite: null },
{
...knee,
encounterId: "enc-3",
reason: " ",
practitionerName: null,
laterality: null,
},
],
null,
labels,
);
expect(choices).toEqual([
{
encounterId: "enc-knee",
name: "Knee arthroscopy",
detail: "Knee (left) · 2025-10-02",
},
{ encounterId: "enc-2", name: "Day clinic", detail: "2025-10-02" },
{
encounterId: "enc-3",
name: "Procedure or surgery",
detail: "Knee · 2025-10-02",
},
]);
});
it("keeps an active procedure the usage list no longer holds, once", () => {
const gone = { ...knee, encounterId: "enc-gone", reason: "Appendectomy" };
const choices = buildProcedureChoices([knee], gone, labels);
expect(choices.map((c) => c.encounterId)).toEqual(["enc-knee", "enc-gone"]);
// Already listed: not added a second time.
expect(buildProcedureChoices([knee], knee, labels)).toHaveLength(1);
});
it("offers nothing when no procedure carries a document", () => {
expect(buildProcedureChoices([], null, labels)).toEqual([]);
});
});
+101 -15
View File
@@ -1,11 +1,12 @@
"use client";
/**
* The vault's sticky filter rail: debounced title/filename search plus three
* The vault's sticky filter rail: debounced title/filename search plus four
* compact dropdown facets — type (multi-select, OR inside the facet),
* condition (episodes that actually carry links, single-select), and year
* (years present in the corpus, single-select) — and a one-tap clear with
* the active-facet count.
* condition (episodes that actually carry links, single-select), procedure
* (procedures and surgeries that actually carry links, single-select), and
* year (years present in the corpus, single-select) — and a one-tap clear
* with the active-facet count.
*
* The controls sit on ONE row at every width: the search flexes and can
* shrink to nothing while each dropdown trigger stays a fixed compact
@@ -24,6 +25,7 @@ import {
ChevronDown,
ListFilter,
ScanSearch,
Scissors,
Search,
X,
} from "lucide-react";
@@ -43,14 +45,26 @@ import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents";
import { DOCUMENT_KIND_ICONS, DOCUMENT_KIND_ORDER } from "./document-kind-meta";
/** Active-facet tint on a dropdown trigger — mirrors the app pill vocabulary. */
const TRIGGER_ACTIVE = "border-primary/40 bg-primary/10 text-foreground";
/** Shared trigger chrome: compact, fixed, never shrinks below its label. */
const TRIGGER_CLASSES = "shrink-0 gap-1.5 font-normal";
import type { ProcedureFilterChoice } from "./vault-utils";
/**
* The facet label is the whole point of the trigger — but three text labels
* Active-facet tint on a dropdown trigger — mirrors the app pill vocabulary.
* An active trigger may also shrink (its label truncates further), so on a
* phone with several facets set the row gives up label width before it gives
* up the search field.
*/
const TRIGGER_ACTIVE =
"border-primary/40 bg-primary/10 text-foreground min-w-0 shrink";
/**
* Shared trigger chrome: compact, fixed, never shrinks below its label. The
* height matches the search field beside it (44 px on a phone, the tap-target
* floor, 40 px from `sm`), so the row reads as one line of controls.
*/
const TRIGGER_CLASSES =
"h-11 min-w-11 shrink-0 gap-1.5 font-normal sm:h-10 sm:min-w-0";
/**
* The facet label is the whole point of the trigger — but four text labels
* plus the search will not fit a 360px phone. So the label shows from `sm`
* up, and on a phone only when the facet is ACTIVE (truncated), while the
* leading icon always carries the facet identity. The trailing chevron is
@@ -59,7 +73,10 @@ const TRIGGER_CLASSES = "shrink-0 gap-1.5 font-normal";
* pinned via `aria-label` so an icon-only trigger still announces its state.
*/
function facetLabelClass(active: boolean): string {
return cn("max-w-28 truncate", active ? "inline" : "hidden sm:inline");
return cn(
"max-w-28 min-w-0 truncate",
active ? "inline" : "hidden sm:inline",
);
}
export interface ConditionChip {
@@ -76,6 +93,9 @@ export function DocumentFilterBar({
conditionChips,
activeEpisodeId,
onToggleEpisode,
procedureChips,
activeEncounterId,
onToggleEncounter,
years,
activeYear,
onToggleYear,
@@ -94,6 +114,9 @@ export function DocumentFilterBar({
conditionChips: ConditionChip[];
activeEpisodeId: string | undefined;
onToggleEpisode: (episodeId: string) => void;
procedureChips: ProcedureFilterChoice[];
activeEncounterId: string | undefined;
onToggleEncounter: (encounterId: string) => void;
years: number[];
activeYear: number | undefined;
onToggleYear: (year: number) => void;
@@ -120,6 +143,12 @@ export function DocumentFilterBar({
const conditionLabel =
activeCondition?.name ?? t("documents.filter.conditionAll");
const activeProcedure = procedureChips.find(
(chip) => chip.encounterId === activeEncounterId,
);
const procedureLabel =
activeProcedure?.name ?? t("documents.filter.procedureAll");
const yearLabel =
activeYear !== undefined
? String(activeYear)
@@ -132,10 +161,12 @@ export function DocumentFilterBar({
>
{/* One row at every width: the search flexes (min-w-0 → it can shrink to
nothing), every dropdown trigger stays a fixed compact control, and
the clear pins to the trailing edge. `flex-nowrap` guarantees the bar
the clear pins to the trailing edge. The search keeps at least its
icon plus its padding (the `/` hint and its padding are desktop
only), so it never bleeds under the next control. `flex-nowrap` guarantees the bar
never breaks to a second line. */}
<div className="flex flex-nowrap items-center gap-2">
<div className="relative min-w-0 flex-1">
<div className="relative min-w-12 flex-1">
<Search
className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2"
aria-hidden
@@ -147,7 +178,10 @@ export function DocumentFilterBar({
onChange={(e) => onSearchChange(e.target.value)}
placeholder={t("documents.filter.searchPlaceholder")}
aria-label={t("documents.filter.searchLabel")}
className="pr-8 pl-9"
// On a phone the field can shrink to its icon; the browser's own
// clear button would then sit on top of the icon, so it goes and
// the row's clear control does the job.
className="min-w-0 pr-3 pl-9 sm:pr-8 max-sm:[&::-webkit-search-cancel-button]:appearance-none"
aria-keyshortcuts="/"
/>
<kbd
@@ -252,6 +286,58 @@ export function DocumentFilterBar({
</DropdownMenu>
) : null}
{/* Procedure — single-select; only rendered when procedures carry
links. Same cadence as the condition facet beside it. */}
{procedureChips.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
data-slot="document-procedure-filter"
aria-label={procedureLabel}
className={cn(
TRIGGER_CLASSES,
activeProcedure && TRIGGER_ACTIVE,
)}
>
<Scissors className="size-4 shrink-0" aria-hidden />
<span className={facetLabelClass(Boolean(activeProcedure))}>
{procedureLabel}
</span>
<ChevronDown
className="hidden size-3.5 opacity-60 sm:inline"
aria-hidden
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
aria-label={t("documents.filter.procedureGroup")}
>
<DropdownMenuLabel>
{t("documents.filter.procedureGroup")}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{procedureChips.map((chip) => (
<DropdownMenuCheckboxItem
key={chip.encounterId}
checked={chip.encounterId === activeEncounterId}
onCheckedChange={() => onToggleEncounter(chip.encounterId)}
>
<span className="flex min-w-0 flex-col">
<span className="max-w-56 truncate">{chip.name}</span>
<span className="text-muted-foreground max-w-56 truncate text-xs">
{chip.detail}
</span>
</span>
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
{/* Year — single-select; only rendered when the corpus spans years. */}
{years.length > 0 ? (
<DropdownMenu>
@@ -306,7 +392,7 @@ export function DocumentFilterBar({
size="sm"
onClick={onClearAll}
aria-label={t("documents.filter.clear")}
className="text-muted-foreground shrink-0"
className="text-muted-foreground h-11 min-w-11 shrink-0 sm:h-10 sm:min-w-0"
>
<X className="size-3.5" aria-hidden />
<span className="hidden sm:inline">
+63 -4
View File
@@ -3,7 +3,7 @@
/**
* The Dokumente vault — one wide, calm, filing-cabinet-fast surface.
*
* Filter state lives in the URL (`?q&kind&episode&year`) so every view is
* Filter state lives in the URL (`?q&kind&episode&encounter&year`) so every view is
* shareable, back-button-safe, and deep-linkable from the illness page and
* labs. Search debounces 200 ms and `/` focuses it. The timeline below is
* virtualized; uploads appear optimistically above it in < 100 ms.
@@ -44,7 +44,10 @@ import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
import { useUrlFilterSync } from "@/hooks/use-url-filter-sync";
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
import { apiGet, apiPost } from "@/lib/api/api-fetch";
import { encounterKindText } from "@/components/encounters/encounter-labels";
import {
bodySiteText,
encounterKindText,
} from "@/components/encounters/encounter-labels";
import { useEncounters } from "@/hooks/use-encounters";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { EncounterKind } from "@/generated/prisma/client";
@@ -73,12 +76,14 @@ import { useReindexAll } from "./use-content-index";
import { useDocumentUpload } from "./use-document-upload";
import { usePageFileDrop } from "./use-page-file-drop";
import {
buildProcedureChoices,
buildVaultListApiSearch,
countActiveFilters,
documentDateKey,
expandRangeSelection,
hasProcessingDocument,
parseVaultSearchParams,
type ProcedureFilterChoice,
resolveBulkShareDocuments,
SHARE_LINK_MAX_DOCUMENTS,
vaultFiltersToSearch,
@@ -345,6 +350,17 @@ export function DocumentsView() {
);
};
const toggleEncounter = (encounterId: string) => {
applyFilters(
{
...filters,
encounterId:
filters.encounterId === encounterId ? undefined : encounterId,
},
"push",
);
};
const toggleYear = (year: number) => {
applyFilters(
{ ...filters, year: filters.year === year ? undefined : year },
@@ -418,15 +434,19 @@ export function DocumentsView() {
enqueueRef.current = upload.enqueue;
}, [upload.enqueue]);
const episodeIdFilter = filters.episodeId;
const encounterIdFilter = filters.encounterId;
const enqueueFiles = useCallback(
(files: File[]) => {
if (files.length === 0) return;
enqueueRef.current(files, { episodeId: episodeIdFilter });
enqueueRef.current(files, {
episodeId: episodeIdFilter,
encounterId: encounterIdFilter,
});
setUploadAnnouncement(
t("documents.upload.queuedAnnouncement", { count: files.length }),
);
},
[episodeIdFilter, t],
[episodeIdFilter, encounterIdFilter, t],
);
const { dropActive } = usePageFileDrop(
canManageDocuments ? enqueueFiles : undefined,
@@ -504,6 +524,42 @@ export function DocumentsView() {
[visits.data, t, format],
);
// Procedure choices: every procedure carrying at least one live document
// link, served by the usage endpoint for the same reason as the condition
// chips, plus the actively filtered visit when it is a procedure (see
// `buildProcedureChoices`).
const procedureChips = useMemo<ProcedureFilterChoice[]>(() => {
const activeVisit = filters.encounterId
? [...(visits.data?.upcoming ?? []), ...(visits.data?.past ?? [])].find(
(v) => v.id === filters.encounterId && v.kind === "PROCEDURE",
)
: undefined;
return buildProcedureChoices(
usage.data?.linkedProcedures ?? [],
activeVisit
? {
encounterId: activeVisit.id,
occurredAt: activeVisit.occurredAt,
reason: activeVisit.reason,
bodySite: activeVisit.bodySite,
laterality: activeVisit.laterality,
practitionerName: activeVisit.practitioner?.name ?? null,
}
: null,
{
kindName: encounterKindText(t, "PROCEDURE"),
siteText: (site, laterality) => bodySiteText(t, site, laterality),
formatDate: (iso) => format.date(iso),
},
);
}, [
usage.data?.linkedProcedures,
filters.encounterId,
visits.data,
t,
format,
]);
// Year segmenter: years present in the loaded corpus (+ the active year).
const years = useMemo(() => {
const set = new Set<number>();
@@ -921,6 +977,9 @@ export function DocumentsView() {
conditionChips={conditionChips}
activeEpisodeId={filters.episodeId}
onToggleEpisode={toggleEpisode}
procedureChips={procedureChips}
activeEncounterId={filters.encounterId}
onToggleEncounter={toggleEncounter}
years={years}
activeYear={filters.year}
onToggleYear={toggleYear}
@@ -59,6 +59,11 @@ export interface UploadQueueItem {
export interface EnqueueOptions {
/** Pre-link every uploaded document to this episode (deep-link uploads). */
episodeId?: string;
/**
* Pre-link every uploaded document to this visit — the active procedure
* filter, so a file dropped into a filtered view stays in it.
*/
encounterId?: string;
}
interface UploadLimits {
@@ -139,6 +144,7 @@ export function uploadViaXhr(
const fd = new FormData();
fd.append("file", file);
if (options.episodeId) fd.append("episodeIds", options.episodeId);
if (options.encounterId) fd.append("encounterIds", options.encounterId);
xhr.send(fd);
});
}
+51
View File
@@ -10,6 +10,7 @@
import {
DOCUMENT_LIST_DEFAULT_LIMIT,
INBOUND_DOCUMENT_KINDS,
type DocumentLinkedProcedureDto,
type InboundDocumentDto,
type InboundDocumentKindValue,
} from "@/lib/validations/inbound-documents";
@@ -455,3 +456,53 @@ export function documentCardKeyAction(key: string): DocumentCardKeyAction {
if (key === "Delete") return "delete";
return null;
}
/** One procedure choice in the filter bar, labelled for the reader. */
export interface ProcedureFilterChoice {
encounterId: string;
/** The heading the visits page shows for it. */
name: string;
/** Body site and date, the line that tells two same-named ones apart. */
detail: string;
}
/**
* The procedure filter's choices: the usage read's linked procedures, plus the
* actively filtered visit when it is a procedure that list does not hold (its
* last link was just removed, or a deep link names it). A filtered visit that
* is not a procedure adds nothing — it is not a procedure choice.
*
* Each is labelled the way the visits page heads the row: the reason, else the
* practice, else the kind's name. The body site (with its side) and the date
* ride the second line. Order is the server's, newest first, with the
* appended active visit last.
*/
export function buildProcedureChoices(
linked: readonly DocumentLinkedProcedureDto[],
active: DocumentLinkedProcedureDto | null,
labels: {
kindName: string;
siteText: (
bodySite: string | null,
laterality: DocumentLinkedProcedureDto["laterality"],
) => string | null;
formatDate: (iso: string) => string;
},
): ProcedureFilterChoice[] {
const sources = [...linked];
if (active && !sources.some((p) => p.encounterId === active.encounterId)) {
sources.push(active);
}
return sources.map((procedure) => {
const site = labels.siteText(procedure.bodySite, procedure.laterality);
const date = labels.formatDate(procedure.occurredAt);
return {
encounterId: procedure.encounterId,
name:
procedure.reason?.trim() ||
procedure.practitionerName?.trim() ||
labels.kindName,
detail: site ? `${site} · ${date}` : date,
};
});
}
@@ -158,16 +158,16 @@ describe("<CoachConversation> page deep-link (#67)", () => {
expect(html).toContain('data-slot="coach-hero"');
});
// v1.21.4 (A) — the page-toolbar gear was removed; Settings now lives in the
// composer's `+` actions menu. The page chrome is the composer alone, so the
// surface exposes the `+` actions trigger and no standalone toolbar gear.
it("drops the page-toolbar gear and exposes the composer + actions menu", () => {
// The page carries no toolbar and the composer no `+` menu: New chat, the
// conversation list and the settings gear live in the conversations panel.
// On the server the panel is closed (it docks only once the client knows
// the viewport), so the SSR markup holds the conversation column alone.
it("renders the conversation column with no toolbar and no composer menu", () => {
const html = render(<CoachConversation surface="page" />, makeClient());
// The old page toolbar + its gear are gone.
expect(html).toContain('data-slot="coach-page-main"');
expect(html).not.toContain('data-slot="coach-page-settings"');
expect(html).not.toContain('data-slot="coach-page-toolbar"');
// Settings now lives behind the composer's `+` actions menu (the menu
// content is portalled open-on-demand, so SSR shows only the trigger).
expect(html).toContain('data-slot="coach-input-actions"');
expect(html).not.toContain('data-slot="coach-input-actions"');
expect(html).not.toContain('href="/coach/conversations"');
});
});
@@ -229,10 +229,9 @@ describe("<CoachInput>", () => {
expect(handler).toHaveBeenCalledWith("typed");
});
it("omits the control-hub action row by default (drawer composer)", () => {
// v1.18.11 (W11) — the hub is page-only; without `showHub` the composer
// stays the single-row drawer layout (mic + send on one baseline) and
// grows no actions menu or settings deep-link.
it("renders the single-row composer with no actions menu", () => {
// The composer carries text, attach and send only; it grows no actions
// menu or settings deep-link on either surface.
const html = render(
<CoachInput value="" onChange={() => {}} onSubmit={() => {}} />,
);
@@ -251,26 +250,25 @@ describe("<CoachInput>", () => {
value=""
onChange={() => {}}
onSubmit={() => {}}
showHub
onNewChat={() => {}}
onOpenHistory={() => {}}
attachEnabled
onPickFromVault={() => {}}
onUploadNew={() => {}}
/>,
);
expect(html).toContain('data-slot="coach-input-controls"');
expect(html).toContain('data-slot="coach-input-leading"');
expect(html).toContain('data-slot="coach-input-actions"');
expect(html).not.toContain('data-slot="coach-input-settings"');
expect(html).toContain('data-slot="coach-input-attach"');
expect(html).not.toContain('data-slot="coach-input-mic"');
expect(html).toContain('data-slot="coach-input-send"');
// DOM and visual order agree at every breakpoint: the textarea comes first,
// followed by the leading actions and then Send.
// followed by the leading attach control and then Send.
const textareaIdx = html.indexOf('data-slot="coach-input-textarea"');
const plusIdx = html.indexOf('data-slot="coach-input-actions"');
const attachIdx = html.indexOf('data-slot="coach-input-attach"');
const sendIdx = html.indexOf('data-slot="coach-input-send"');
expect(textareaIdx).toBeGreaterThan(-1);
expect(textareaIdx).toBeLessThan(plusIdx);
expect(plusIdx).toBeLessThan(sendIdx);
expect(textareaIdx).toBeLessThan(attachIdx);
expect(attachIdx).toBeLessThan(sendIdx);
const controls = html.match(
/<div[^>]*data-slot="coach-input-controls"[^>]*>/,
@@ -295,22 +293,28 @@ describe("<CoachInput>", () => {
expect(controls?.[0]).toMatch(/\bself-end\b/);
});
it("sizes the hub actions trigger to the 44px tap-target floor on phones", () => {
it("carries no conversation menu: New chat, Conversations and Settings live in the page's panel", () => {
const html = render(
<CoachInput
value=""
onChange={() => {}}
onSubmit={() => {}}
showHub
onNewChat={() => {}}
onOpenHistory={() => {}}
attachEnabled
onPickFromVault={() => {}}
onUploadNew={() => {}}
/>,
);
const actions = html.match(
/<button[^>]*data-slot="coach-input-actions"[^>]*>/,
expect(html).not.toContain('data-slot="coach-input-actions"');
expect(html).not.toContain("coach-input-action-new-chat");
expect(html).not.toContain("coach-input-action-history");
expect(html).not.toContain("coach-input-action-settings");
expect(html).not.toContain('href="/settings/ai"');
// The attach control stays, at the 44 px tap-target floor on phones.
const attach = html.match(
/<button[^>]*data-slot="coach-input-attach"[^>]*>/,
);
expect(actions?.[0]).toMatch(/\bsize-11\b/);
expect(actions?.[0]).toMatch(/\bsm:size-9\b/);
expect(attach?.[0]).toMatch(/\bsize-11\b/);
expect(attach?.[0]).toMatch(/\bsm:size-9\b/);
});
it("renders the textarea at rows=1 initial state (W5 auto-grow baseline)", () => {
@@ -0,0 +1,139 @@
import { describe, expect, it } from "vitest";
import type {
ProviderChainData,
UserAIProvider,
} from "@/components/settings/ai/shared";
import {
chainWithFirst,
modelControlFor,
reasoningFieldFor,
selectableProviders,
switchDidNotTake,
} from "../coach-model-picker";
const CHAIN: ProviderChainData["configuredChain"] = [
{ providerType: "codex", enabled: true, available: false },
{ providerType: "openai", enabled: true, available: true },
{ providerType: "anthropic", enabled: false, available: true },
{ providerType: "local", enabled: true, available: true },
{ providerType: "admin-openai", enabled: true, available: true },
];
function userProvider(over: Partial<UserAIProvider> = {}): UserAIProvider {
return {
provider: "OPENAI",
model: "gpt-4o",
baseUrl: null,
hasAnthropicKey: false,
anthropicKeyPreview: null,
hasLocalKey: false,
hasOpenaiKey: true,
openaiKeyPreview: "sk-…abcd",
compatBaseUrl: null,
compatModel: null,
hasCompatKey: false,
responseTimeoutSeconds: null,
localReasoningEffort: null,
compatReasoningEffort: null,
...over,
};
}
describe("chainWithFirst", () => {
it("moves the chosen provider to the front and keeps the rest in order", () => {
expect(chainWithFirst(CHAIN, "local")).toEqual([
{ providerType: "local", priority: 1, enabled: true },
{ providerType: "codex", priority: 2, enabled: true },
{ providerType: "openai", priority: 3, enabled: true },
{ providerType: "anthropic", priority: 4, enabled: false },
{ providerType: "admin-openai", priority: 5, enabled: true },
]);
});
it("never sends a reasoning setting, so the server keeps the stored one", () => {
for (const entry of chainWithFirst(CHAIN, "openai")) {
expect(Object.keys(entry).sort()).toEqual([
"enabled",
"priority",
"providerType",
]);
}
});
});
describe("selectableProviders", () => {
it("offers only enabled providers that can answer", () => {
expect(selectableProviders(CHAIN)).toEqual([
"openai",
"local",
"admin-openai",
]);
});
});
describe("switchDidNotTake", () => {
it("is quiet when the chosen provider leads the chain the server resolves", () => {
expect(switchDidNotTake("openai", { activeProvider: "openai" })).toBe(
false,
);
});
it("speaks up when the server still answers with another provider", () => {
// A provider without a working key was saved to the front of the chain;
// the resolver skipped it and the select would jump back silently.
expect(switchDidNotTake("anthropic", { activeProvider: "openai" })).toBe(
true,
);
expect(switchDidNotTake("anthropic", { activeProvider: null })).toBe(true);
});
});
describe("modelControlFor", () => {
it("offers the presets for the provider whose key the person saved", () => {
const control = modelControlFor("openai", userProvider());
expect(control.kind).toBe("preset");
if (control.kind === "preset") expect(control.value).toBe("gpt-4o");
});
it("does not offer a model for a provider the shared model column is not set up for", () => {
// The column belongs to OpenAI here; a Claude model name written for the
// Anthropic entry would be sent to OpenAI.
expect(modelControlFor("anthropic", userProvider()).kind).toBe("managed");
});
it("keeps the operator's provider and Codex read-only", () => {
expect(modelControlFor("admin-openai", userProvider()).kind).toBe(
"managed",
);
expect(modelControlFor("codex", userProvider()).kind).toBe("managed");
expect(modelControlFor(null, userProvider()).kind).toBe("managed");
});
it("gives the gateway its own free-text model", () => {
expect(
modelControlFor(
"openai-compatible",
userProvider({ compatModel: "qwen3:32b" }),
),
).toEqual({ kind: "gateway", value: "qwen3:32b" });
});
});
describe("reasoningFieldFor", () => {
it("writes the Local and gateway entries' own reasoning fields", () => {
expect(
reasoningFieldFor("local", userProvider({ localReasoningEffort: "low" })),
).toEqual({ field: "localReasoningEffort", value: "low" });
expect(reasoningFieldFor("openai-compatible", userProvider())).toEqual({
field: "compatReasoningEffort",
value: null,
});
});
it("has nothing for providers without a reasoning setting", () => {
expect(reasoningFieldFor("openai", userProvider())).toBeNull();
expect(reasoningFieldFor("codex", userProvider())).toBeNull();
});
});
@@ -0,0 +1,145 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { DEFAULT_COACH_PREFS } from "@/lib/validations/coach-prefs";
import type {
ProviderChainData,
UserAIProvider,
} from "@/components/settings/ai/shared";
import { CoachSettingsOverlay } from "../coach-settings-overlay";
import {
COACH_SETTINGS_DATA_SECTION_ID,
CoachSettingsBody,
} from "../coach-settings-body";
const CHAIN: ProviderChainData = {
activeProvider: "local",
cachedActiveProvider: "local",
configuredChain: [
{ providerType: "codex", enabled: true, available: false },
{ providerType: "local", enabled: true, available: true },
{ providerType: "admin-openai", enabled: true, available: true },
],
};
const PROVIDER: UserAIProvider = {
provider: "LOCAL",
model: "qwen2.5",
baseUrl: "http://localhost:11434/v1",
hasAnthropicKey: false,
anthropicKeyPreview: null,
hasLocalKey: false,
hasOpenaiKey: false,
openaiKeyPreview: null,
compatBaseUrl: null,
compatModel: null,
hasCompatKey: false,
responseTimeoutSeconds: null,
localReasoningEffort: "none",
compatReasoningEffort: null,
};
function render(node: React.ReactNode, chain: ProviderChainData = CHAIN) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
client.setQueryData(queryKeys.insightsProviderChain(), chain);
client.setQueryData(queryKeys.userAiProvider(), PROVIDER);
client.setQueryData(queryKeys.coachPrefs(), DEFAULT_COACH_PREFS);
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<QueryClientProvider client={client}>{node}</QueryClientProvider>
</I18nProvider>,
);
}
describe("<CoachSettingsBody>", () => {
it("holds the model choice, what the Coach sees, and a way to the full settings", () => {
const html = render(<CoachSettingsBody />);
expect(html).toContain('data-slot="coach-settings-model"');
expect(html).toContain('data-slot="coach-model-picker"');
expect(html).toContain(`id="${COACH_SETTINGS_DATA_SECTION_ID}"`);
// "What I can see" is the existing rail, wired to the saved preference:
// the lookback control and the data-area switches.
expect(html).toContain('data-slot="coach-sources-window"');
expect(html).toContain('data-slot="coach-sources-list"');
expect(html).toMatch(/href="\/settings\/ai"[^>]*>All AI settings</);
});
it("offers only the providers that can answer, with the active one selected", () => {
const html = render(<CoachSettingsBody />);
const select = html.match(
/<select[^>]*data-slot="coach-quick-provider"[\s\S]*?<\/select>/,
)?.[0];
expect(select).toBeDefined();
expect(select).toContain('value="local"');
expect(select).toContain('value="admin-openai"');
expect(select).not.toContain('value="codex"');
});
it("shows the operator's central Codex as the active provider when it answers", () => {
// `admin-codex` is never a chain entry, so no option matched it and the
// select showed its first option as the one answering.
const html = render(<CoachSettingsBody />, {
...CHAIN,
activeProvider: "admin-codex" as ProviderChainData["activeProvider"],
});
const select = html.match(
/<select[^>]*data-slot="coach-quick-provider"[\s\S]*?<\/select>/,
)?.[0];
expect(select).toMatch(
/<option value="admin-codex" disabled="" selected="">ChatGPT \(shared by the operator\)<\/option>/,
);
expect(select).not.toMatch(/value="local"[^>]*selected/);
expect(html).toContain('data-slot="coach-quick-provider-operator"');
});
it("shows the model and reasoning controls the active provider owns", () => {
const html = render(<CoachSettingsBody />);
expect(html).toContain('data-slot="coach-quick-model"');
expect(html).toContain('id="coach-quick-reasoning"');
// Keys, base URLs and the chain order are not here.
expect(html).not.toContain("http://localhost:11434/v1");
expect(html).not.toMatch(/type="password"/);
});
it("locks the reasoning select while a change is saving", async () => {
// Two writes in flight at once could land out of order and lose one;
// the provider and model selects already lock, the reasoning one too.
const source = (await import("node:fs")).readFileSync(
new URL("../coach-model-picker.tsx", import.meta.url),
"utf8",
);
const field = source.match(/<ReasoningEffortField[\s\S]*?\/>/)?.[0];
expect(field).toMatch(/disabled=\{busy\}/);
});
it("names the data section so a deep link can land on it", () => {
const html = render(<CoachSettingsBody />);
expect(html).toMatch(
new RegExp(
`<section[^>]*id="${COACH_SETTINGS_DATA_SECTION_ID}"[^>]*tabindex="-1"`,
),
);
});
});
describe("<CoachSettingsOverlay>", () => {
it("renders a labelled gear that announces a dialog and its state", () => {
const html = render(
<CoachSettingsOverlay open={false} onOpenChange={() => {}} />,
);
const gear = html.match(
/<button[^>]*data-slot="coach-settings"[^>]*>/,
)?.[0];
expect(gear).toContain('aria-label="Coach settings"');
expect(gear).toContain('aria-haspopup="dialog"');
expect(gear).toContain('aria-expanded="false"');
// Closed: no popover content in the markup.
expect(html).not.toContain('data-slot="coach-settings-popover"');
});
});
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { sheetShown } from "../conversations-panel";
/**
* `/coach?settings=data` opens the phone sheet, and only the phone sheet.
* The server and hydration renders take the narrow layout (the viewport is
* not known yet), so a deep link that opened the sheet from the first
* render flashed it on a desktop before the docked panel replaced it.
*/
describe("sheetShown", () => {
const deepLink = { chosen: false, deepLinkPending: true };
it("keeps the sheet shut while the viewport is not known", () => {
expect(sheetShown({ ...deepLink, hydrated: false, docked: false })).toBe(
false,
);
});
it("never opens the sheet for a deep link on a docked viewport", () => {
expect(sheetShown({ ...deepLink, hydrated: true, docked: true })).toBe(
false,
);
});
it("opens the sheet for a deep link on a phone once hydrated", () => {
expect(sheetShown({ ...deepLink, hydrated: true, docked: false })).toBe(
true,
);
});
it("follows the person's own choice otherwise", () => {
const base = { deepLinkPending: false, hydrated: true, docked: false };
expect(sheetShown({ ...base, chosen: true })).toBe(true);
expect(sheetShown({ ...base, chosen: false })).toBe(false);
});
});
@@ -166,25 +166,46 @@ describe("<HistoryRail>", () => {
expect(html).toContain("Deine Unterhaltungen erscheinen hier");
});
it("renders the delete button for each conversation row", () => {
it("gives each row one labelled actions menu instead of inline buttons", () => {
const client = makeClientWithConversations(samplePage);
const html = render(
<HistoryRail activeId={null} onSelect={() => {}} />,
client,
);
const buttons = html.match(/data-slot="coach-history-delete"/g) ?? [];
expect(buttons.length).toBe(2);
const menus =
html.match(/<button[^>]*data-slot="coach-history-row-menu"[^>]*>/g) ?? [];
expect(menus.length).toBe(2);
expect(menus[0]).toContain('aria-haspopup="menu"');
expect(html).toContain(
'aria-label="Actions for Why was BP higher on Monday?"',
);
// Rename and Delete sit inside the menu (portalled on open), so the row
// itself carries neither a pencil nor a trash button any more.
expect(html).not.toContain('data-slot="coach-conversation-rename"');
expect(html).not.toContain('data-slot="coach-history-delete"');
});
it("renders an accessible rename button for each conversation row", () => {
it("marks the open conversation with aria-current", () => {
const client = makeClientWithConversations(samplePage);
const html = render(
<HistoryRail activeId="c2" onSelect={() => {}} />,
client,
);
const current = html.match(/<button[^>]*aria-current="true"[^>]*>/g) ?? [];
expect(current).toHaveLength(1);
expect(current[0]).toContain('title="Compare this week to last month"');
});
it("groups the rows under recency headings", () => {
const client = makeClientWithConversations(samplePage);
const html = render(
<HistoryRail activeId={null} onSelect={() => {}} />,
client,
);
const buttons = html.match(/data-slot="coach-conversation-rename"/g) ?? [];
expect(buttons.length).toBe(2);
expect(html).toContain('aria-label="Rename conversation"');
// Both sample threads are months old, so they share the Earlier group.
expect(html.match(/data-slot="coach-history-group"/g)).toHaveLength(1);
expect(html).toContain('data-group="earlier"');
expect(html).toMatch(/<h3[^>]*>Earlier<\/h3>/);
});
// v1.30.2 (QoL H1) — infinite history: reachability beyond the first page.
@@ -0,0 +1,292 @@
/**
* The one action row under a Coach message, and the follow-up chips that
* live in the answer's column.
*
* - Assistant: copy, read aloud, try again, then the time, in one left
* group; the tokens and model in the right group of the same row. No
* feedback thumbs, no evidence disclosure.
* - User: copy, remember, time in one row; remember is an icon.
* - The time reads "14:32" today, "yesterday 14:32", or the date further
* back.
* - The chips of the latest answer render inside its column, after the
* action row, at the answer's text size; the memo comparator sees a new
* offer.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import type { CoachFollowUp } from "@/lib/ai/coach/types";
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ user: null, isAuthenticated: true, isLoading: false }),
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: undefined }),
useMutation: () => ({ mutate: vi.fn(), isPending: false, isError: false }),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
}));
vi.mock("@/hooks/use-coach-message-results", () => ({
useCoachMessageResults: () => ({
ref: () => {},
results: undefined,
isLoading: false,
isError: false,
refetch: () => {},
}),
}));
// SSR resolves the clipboard and speech capabilities to "absent"; claim
// both so the full row renders and its order can be read.
vi.mock("../read-aloud", async (importOriginal) => {
const actual = await importOriginal<typeof import("../read-aloud")>();
return {
...actual,
useClipboardSupported: () => true,
ReadAloudButton: () => (
<button type="button" data-slot="coach-read-aloud" aria-label="Read" />
),
};
});
import { ChatBubble, areChatBubblePropsEqual } from "../chat-bubble";
import { answerMetaText, messageTimeText } from "../message-actions";
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
/** The markup of the element carrying `slot`, up to its matching close. */
function slot(html: string, name: string): string {
const start = html.indexOf(`data-slot="${name}"`);
if (start < 0) return "";
const open = html.lastIndexOf("<", start);
const tag = html.slice(open + 1).match(/^[a-z]+/)![0];
let depth = 0;
const re = new RegExp(`<${tag}[\\s>]|</${tag}>`, "g");
re.lastIndex = open;
for (let m = re.exec(html); m; m = re.exec(html)) {
depth += m[0].startsWith("</") ? -1 : 1;
if (depth === 0) return html.slice(open, m.index + m[0].length);
}
return html.slice(open);
}
const CHIPS: CoachFollowUp[] = [
{
id: "f1",
kind: "previous_period",
labelKey: "coach.followUp.previousPeriod",
label: "Compare with the period before",
reuse: false,
origin: "server",
},
];
const ANSWER = {
role: "assistant" as const,
content: "Your blood pressure held steady.",
metricSource: null,
providerType: "openai",
messageId: "m2",
createdAt: new Date().toISOString(),
tokensUsed: 1234,
model: "gpt-4o-mini",
onRegenerate: () => {},
};
describe("assistant action row", () => {
const html = render(<ChatBubble {...ANSWER} />);
const row = slot(html, "coach-answer-actions");
const left = slot(row, "coach-answer-actions-left");
it("lists copy, read aloud, try again and the time on the left, in that order", () => {
const order = [
"coach-copy-message",
"coach-read-aloud",
"coach-try-again",
"coach-message-time",
].map((name) => left.indexOf(`data-slot="${name}"`));
expect(order.every((at) => at > -1)).toBe(true);
expect([...order].sort((a, b) => a - b)).toEqual(order);
});
it("puts the tokens and the model on the right of the same row", () => {
const meta = slot(row, "coach-answer-meta");
expect(meta).toContain("1,234 tokens · gpt-4o-mini");
expect(meta).toContain('title="gpt-4o-mini"');
expect(meta).toContain("truncate");
expect(left).not.toContain("coach-answer-meta");
expect(row).toContain("justify-between");
});
it("has no thumbs, no evidence disclosure and no separate token line", () => {
expect(html).not.toContain("coach-message-feedback");
expect(html).not.toContain("coach-evidence");
expect(html).not.toContain("coach-token-footer");
});
it("shows the time as text, the full date for screen readers", () => {
const time = slot(left, "coach-message-time");
expect(time).toMatch(/^<time[^>]*dateTime="/);
expect(time).toContain("Message sent");
expect(time).toMatch(/<span aria-hidden="true">[^<]*\d:\d{2}[^<]*<\/span>/);
});
it("keeps an empty right group when the answer has no count", () => {
const bare = render(
<ChatBubble {...ANSWER} tokensUsed={null} model={null} />,
);
expect(slot(bare, "coach-answer-meta")).toMatch(
/<p data-slot="coach-answer-meta"[^>]*><\/p>/,
);
});
it("is absent on a refusal and while the answer is in flight", () => {
expect(
render(<ChatBubble {...ANSWER} providerType="refusal" />),
).not.toContain("coach-answer-actions");
expect(
render(<ChatBubble {...ANSWER} inProgress streaming />),
).not.toContain("coach-answer-actions");
});
});
describe("user action row", () => {
const html = render(
<ChatBubble
role="user"
content="I am allergic to peanuts"
messageId="m1"
createdAt={new Date().toISOString()}
/>,
);
const row = slot(html, "coach-user-actions");
it("holds copy, remember and the time in one row, flush right", () => {
const order = [
"coach-copy-message",
"coach-remember-message",
"coach-message-time",
].map((name) => row.indexOf(`data-slot="${name}"`));
expect(order.every((at) => at > -1)).toBe(true);
expect([...order].sort((a, b) => a - b)).toEqual(order);
expect(row).toContain("justify-end");
});
it("renders remember as a labelled icon button, not a text button", () => {
const button = slot(row, "coach-remember-message");
expect(button).toContain('aria-label="Remember"');
expect(button).toContain('data-state="idle"');
expect(button).toContain("<svg");
expect(button).not.toMatch(/>Remember</);
});
});
describe("follow-up chips in the answer column", () => {
const offer = { messageId: "m2", followUps: CHIPS };
it("render inside the column, after the action row", () => {
const html = render(
<ChatBubble {...ANSWER} followUps={offer} onFollowUp={() => {}} />,
);
const column = slot(html, "coach-answer-column");
const actions = column.indexOf('data-slot="coach-answer-actions"');
const chips = column.indexOf('data-slot="coach-follow-up-chips"');
expect(actions).toBeGreaterThan(-1);
expect(chips).toBeGreaterThan(actions);
// The answer's own text size, not the button's default.
expect(slot(column, "coach-follow-up-chips")).toMatch(
/class="[^"]*\btext-sm\b[^"]*font-normal/,
);
});
it("step aside while another turn runs", () => {
const html = render(
<ChatBubble
{...ANSWER}
followUps={offer}
followUpsDisabled
onFollowUp={() => {}}
/>,
);
expect(html).not.toContain("coach-follow-up-chips");
});
it("re-render the memoised bubble when the offer changes, not when only its wrapper does", () => {
const onFollowUp = () => {};
const base = { ...ANSWER, onFollowUp };
expect(
areChatBubblePropsEqual(
{ ...base, followUps: offer },
{ ...base, followUps: { ...offer } },
),
).toBe(true);
expect(
areChatBubblePropsEqual(
{ ...base, followUps: null },
{ ...base, followUps: offer },
),
).toBe(false);
expect(
areChatBubblePropsEqual(
{ ...base, followUps: offer },
{ ...base, followUps: { ...offer, followUps: [...CHIPS] } },
),
).toBe(false);
expect(
areChatBubblePropsEqual(
{ ...base, followUps: offer },
{ ...base, followUps: offer, followUpsDisabled: true },
),
).toBe(false);
});
});
describe("messageTimeText", () => {
const { t } = getServerTranslator("en");
// Day keys from the ISO date, the clock from its time: enough to pin the
// three tiers without a zone.
const formatters = {
dateShortSmart: (v: string | number | Date) =>
new Date(v).toISOString().slice(0, 10),
time: (v: string | number | Date) =>
new Date(v).toISOString().slice(11, 16),
};
const now = Date.parse("2026-10-04T12:00:00.000Z");
it("is the clock alone today", () => {
expect(
messageTimeText("2026-10-04T08:15:00.000Z", now, formatters, t),
).toBe("08:15");
});
it("says yesterday the day before", () => {
expect(
messageTimeText("2026-10-03T21:40:00.000Z", now, formatters, t),
).toBe("yesterday 21:40");
});
it("adds the date further back", () => {
expect(
messageTimeText("2026-09-28T07:05:00.000Z", now, formatters, t),
).toBe("2026-09-28, 07:05");
});
});
describe("answerMetaText", () => {
const { t } = getServerTranslator("en");
it("formats the count for the locale and adds the model", () => {
expect(answerMetaText(1234, "gpt-4o", "en-US", t)).toBe(
"1,234 tokens · gpt-4o",
);
expect(answerMetaText(1234, null, "de-DE", t)).toBe("1.234 tokens");
});
it("is nothing without a count", () => {
expect(answerMetaText(null, "gpt-4o", "en-US", t)).toBeNull();
});
});
@@ -154,7 +154,7 @@ const baseConversation: CoachConversationDetailDTO = {
};
describe("<MessageThread>", () => {
it("dates a table copied from an earlier answer by that answer, and widens the reply for it", () => {
it("dates a table copied from an earlier answer by that answer, in the full-width answer column", () => {
const reused: CoachResultTable = {
ref: "r1",
source: {
@@ -211,7 +211,7 @@ describe("<MessageThread>", () => {
// m2 was written on 10 May; the copy in m3 names that day.
expect(html).toMatch(/From an earlier answer \([^)]*10[^)]*\)/);
expect(html).toMatch(
/<div class="flex max-w-\[calc\(80%-2\.625rem\)\] flex-col gap-2 w-full">/,
/data-slot="coach-answer-column" class="[^"]*\bw-full\b[^"]*"/,
);
} finally {
storedResults.value = undefined;
@@ -248,12 +248,13 @@ describe("<MessageThread>", () => {
);
});
it("renders source chips below an assistant bubble that carries provenance", () => {
it("folds an older message's provenance into the areas header, never a raw key", () => {
const html = render(<MessageThread conversation={baseConversation} />);
expect(html).toContain('data-slot="coach-source-chips"');
expect(html).toContain('data-metric="bp"');
expect(html).toContain("last 30 days");
expect(html).toContain("n=26");
expect(html).toContain('data-slot="coach-turn-steps-done"');
expect(html).toContain("Looked at 1 area");
expect(html).not.toContain('data-slot="coach-source-chips"');
expect(html).not.toContain("insights.coach.metric.");
expect(html).not.toContain("n=26");
});
it("renders an in-flight streaming bubble alongside persisted history", () => {
@@ -447,11 +448,7 @@ describe("<MessageThread>", () => {
);
});
it("renders the evidence-block disclosure when keyValues is non-empty (EN)", () => {
// v1.4.22 — the Coach surfaces load-bearing numbers in a
// collapsible "What I'm looking at" disclosure under the assistant
// bubble. Verify the structure renders correctly and the entries
// show label / value / unit / window.
it("shows no evidence disclosure and no raw key values under an answer", () => {
const withKeyValues: CoachConversationDetailDTO = {
...baseConversation,
messages: [
@@ -468,63 +465,30 @@ describe("<MessageThread>", () => {
unit: "mmHg",
window: "last7days",
},
{
label: "avg30 systolic",
value: "134",
unit: "mmHg",
window: "last30days",
},
],
},
},
],
};
const html = render(<MessageThread conversation={withKeyValues} />);
expect(html).toContain('data-slot="coach-evidence"');
expect(html).toContain("What I&#x27;m looking at");
expect(html).toContain('data-slot="coach-evidence-list"');
const rows = (html.match(/data-slot="coach-evidence-row"/g) ?? []).length;
expect(rows).toBe(2);
// v1.4.25 W5 — per-row source labels were dropped. The values +
// units + window framing stay; the redundant `kv.label` prefix
// (e.g. "avg7 systolic:") does not appear.
expect(html).not.toContain("avg7 systolic");
expect(html).not.toContain("avg30 systolic");
expect(html).toContain("138 mmHg");
expect(html).toContain("(last7days)");
expect(html).toContain("134 mmHg");
// Disclosure is collapsed by default (no `open` attribute).
expect(html).not.toMatch(/<details[^>]*\bopen\b/);
expect(html).not.toContain('data-slot="coach-evidence"');
expect(html).not.toContain("What I&#x27;m looking at");
expect(html).not.toContain("138 mmHg");
// The areas the answer drew on stay reachable from the header.
expect(html).toContain("Looked at 1 area");
});
it("renders the evidence-block label in German under 'de' locale", () => {
const withKeyValues: CoachConversationDetailDTO = {
...baseConversation,
messages: [
baseConversation.messages[0],
{
...baseConversation.messages[1],
metricSource: {
windows: ["last30days"],
metrics: ["bp"],
keyValues: [
{
label: "avg30 systolisch",
value: "138",
unit: "mmHg",
window: "last30days",
},
],
},
},
],
};
const html = render(<MessageThread conversation={withKeyValues} />, "de");
expect(html).toContain("Worauf bezieht sich das?");
it("names the areas header in German under 'de'", () => {
const html = render(
<MessageThread conversation={baseConversation} />,
"de",
);
expect(html).toContain("1 Bereich angesehen");
expect(html).not.toContain("Worauf bezieht sich das?");
});
it("renders entries without unit or window cleanly", () => {
const withKeyValues: CoachConversationDetailDTO = {
it("leaves out a metric token the bundle cannot name", () => {
const unknown: CoachConversationDetailDTO = {
...baseConversation,
messages: [
baseConversation.messages[0],
@@ -532,38 +496,15 @@ describe("<MessageThread>", () => {
...baseConversation.messages[1],
metricSource: {
windows: [],
metrics: ["compliance"],
keyValues: [{ label: "30-day adherence", value: "96" }],
// A token an older seed wrote; it names nothing.
metrics: ["bloodPressure" as never, "general"],
},
},
],
};
const html = render(<MessageThread conversation={withKeyValues} />);
// v1.4.25 W5 — `kv.label` no longer renders; only the value
// bubbles up. The row still mounts so the value is visible.
expect(html).not.toContain("30-day adherence");
expect(html).toContain('data-slot="coach-evidence-row"');
expect(html).toMatch(/<strong[^>]*>\s*96\s*<\/strong>/);
});
it("folds the source chips into the collapsed disclosure (no key-values)", () => {
// v1.12.0 — the provenance block is now a single collapsed
// disclosure. baseConversation.messages[1].metricSource carries
// metrics + windows but no `keyValues`, so the disclosure renders
// (collapsed, with the chips inside) but the key-value list does
// not. The chips no longer paint outside / above the disclosure.
const html = render(<MessageThread conversation={baseConversation} />);
expect(html).toContain('data-slot="coach-evidence"');
expect(html).toContain('data-slot="coach-source-chips"');
expect(html).not.toContain('data-slot="coach-evidence-list"');
// Collapsed by default.
expect(html).not.toMatch(/<details[^>]*\bopen\b/);
// The chips render inside the `<details>` shell, not as a sibling
// above it.
const detailsIdx = html.indexOf('data-slot="coach-evidence"');
const chipsIdx = html.indexOf('data-slot="coach-source-chips"');
expect(detailsIdx).toBeGreaterThanOrEqual(0);
expect(chipsIdx).toBeGreaterThan(detailsIdx);
const html = render(<MessageThread conversation={unknown} />);
expect(html).not.toContain('data-slot="coach-turn-steps"');
expect(html).not.toContain("bloodPressure");
});
it("renders no disclosure when there is no provenance at all", () => {
@@ -579,7 +520,7 @@ describe("<MessageThread>", () => {
};
const html = render(<MessageThread conversation={noProvenance} />);
expect(html).not.toContain('data-slot="coach-evidence"');
expect(html).not.toContain('data-slot="coach-source-chips"');
expect(html).not.toContain('data-slot="coach-turn-steps"');
});
it("renders the user bubble with the self-hosted avatar when one is set (v1.5.5)", () => {
@@ -689,40 +630,53 @@ describe("<MessageThread>", () => {
expect(html).not.toContain('data-slot="coach-remember-message"');
});
it("reveals the remember control on bubble hover/focus at sm+, keeps it visible on touch", () => {
it("reveals the user action row on bubble hover/focus at sm+, keeps it visible on touch", () => {
const html = render(<MessageThread conversation={baseConversation} />);
const button = html.match(
/<button[^>]*data-slot="coach-remember-message"[^>]*>/,
const row = html.match(
/<div[^>]*data-slot="coach-user-actions"[^>]*>/,
)?.[0];
expect(button).toBeTruthy();
expect(row).toBeTruthy();
// Hidden only behind BOTH gates — `sm:` viewport AND a
// hover-capable pointer — so touch devices (no hover media) keep
// the control always visible.
expect(button).toContain("sm:[@media(hover:hover)]:opacity-0");
expect(button).toContain(
// the row always visible.
expect(row).toContain("sm:[@media(hover:hover)]:opacity-0");
expect(row).toContain(
"sm:[@media(hover:hover)]:group-hover/user-bubble:opacity-100",
);
expect(button).toContain(
expect(row).toContain(
"sm:[@media(hover:hover)]:group-focus-within/user-bubble:opacity-100",
);
// The bubble column is the named hover/focus group.
expect(html).toContain("group/user-bubble");
});
it("hands focus to the settled confirmation instead of dropping it to body", () => {
// The settled branch unmounts the button the user just activated;
// the status paragraph takes the focus (`tabIndex={-1}` +
// programmatic focus on settle). SSR cannot exercise the focus
// call, so the wiring is pinned structurally.
it("puts remember in the user's action row, not on a line of its own", () => {
const html = render(<MessageThread conversation={baseConversation} />);
const row = html.match(
/<div[^>]*data-slot="coach-user-actions"[^>]*>([\s\S]*?)<\/div>/,
)?.[1];
expect(row).toBeTruthy();
const copy = row!.indexOf('data-slot="coach-remember-message"');
const time = row!.indexOf('data-slot="coach-message-time"');
expect(copy).toBeGreaterThan(-1);
expect(time).toBeGreaterThan(copy);
expect(html).not.toContain('data-slot="coach-remember-message-done"');
});
it("keeps the remember button mounted once it has settled, so focus stays on it", () => {
// A disabled or unmounted button drops keyboard focus to <body>; the
// settled button only turns inert. SSR cannot click, so the wiring is
// pinned structurally.
const src = readFileSync(
join(
process.cwd(),
"src/components/insights/coach-panel/chat-bubble.tsx",
"src/components/insights/coach-panel/message-actions.tsx",
),
"utf8",
);
expect(src).toContain("statusRef.current?.focus()");
expect(src).toMatch(/ref=\{statusRef\}\s*\n\s*tabIndex=\{-1\}/);
expect(src).toContain("aria-disabled={inert || undefined}");
expect(src).not.toMatch(
/data-slot="coach-remember-message"[\s\S]{0,400}(?<![-\w])disabled=\{/,
);
});
// v1.4.25 W5 — distinct daily-limit vs provider-rate-limit copy.
@@ -0,0 +1,66 @@
/**
* The jump-to-latest button: invisible and out of reach at the end of the
* thread, visible and reachable away from it, and without a transition
* under reduced motion. The thread drives `visible` from its scroll state.
*/
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { I18nProvider } from "@/lib/i18n/context";
import { ScrollToBottomButton } from "../scroll-to-bottom-button";
function render(visible: boolean) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<ScrollToBottomButton visible={visible} onClick={() => {}} />
</I18nProvider>,
);
}
describe("<ScrollToBottomButton>", () => {
it("is hidden, inert and out of the tab order at the end", () => {
const html = render(false);
expect(html).toContain('data-visible="false"');
expect(html).toContain('aria-hidden="true"');
expect(html).toContain('tabindex="-1"');
expect(html).toMatch(/class="[^"]*\bopacity-0\b/);
expect(html).toMatch(/class="[^"]*\spointer-events-none\s/);
});
it("is visible, labelled and focusable away from the end", () => {
const html = render(true);
const button = html.match(/<button[^>]*>/)![0];
expect(button).toContain('data-visible="true"');
expect(button).toContain('aria-label="Jump to the latest message"');
expect(button).not.toContain("aria-hidden");
expect(button).not.toContain("tabindex");
expect(html).toMatch(/class="[^"]*\bopacity-100\b/);
expect(button).not.toMatch(/[\s"]pointer-events-none[\s"]/);
});
it("fades without motion when the reader asks for less", () => {
expect(render(true)).toContain("motion-reduce:transition-none");
});
it("is wired to the thread's pinned state and returns focus to the composer", () => {
// SSR cannot scroll; the wiring is pinned structurally.
const src = readFileSync(
join(
process.cwd(),
"src/components/insights/coach-panel/message-thread.tsx",
),
"utf8",
);
expect(src).toContain(
"<ScrollToBottomButton visible={!pinned} onClick={scrollToLatest} />",
);
// Focus leaves the button BEFORE the state that hides it changes: a
// focused control under aria-hidden is announced as nothing.
expect(src).toMatch(
/scrollToLatest = useCallback\(\(\) => \{[^}]*?focusCoachComposer\(\);[^}]*?setPinned\(true\);/,
);
});
});
@@ -1,120 +0,0 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { SourceChips } from "../source-chips";
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
function classesForSlot(html: string, slot: string): string[] {
const elements =
html.match(new RegExp(`<[^>]+data-slot="${slot}"[^>]*>`, "g")) ?? [];
return elements.map(
(element) => element.match(/\bclass="([^"]*)"/)?.[1] ?? "",
);
}
describe("<SourceChips>", () => {
it("renders nothing when provenance is null", () => {
const html = render(<SourceChips provenance={null} />);
expect(html).not.toContain('data-slot="coach-source-chips"');
});
it("renders nothing when both windows + metrics are empty", () => {
const html = render(
<SourceChips provenance={{ windows: [], metrics: [] }} />,
);
expect(html).not.toContain('data-slot="coach-source-chips"');
});
it("renders one chip per metric, paired with the primary window", () => {
const html = render(
<SourceChips
provenance={{
windows: ["last30days"],
metrics: ["bp", "pulse"],
counts: { bp: 12, pulse: 14 },
}}
/>,
);
expect(html).toContain('data-slot="coach-source-chips"');
const matches = html.match(/data-slot="coach-source-chip"/g) ?? [];
expect(matches.length).toBe(2);
expect(html).toContain('data-metric="bp"');
expect(html).toContain('data-metric="pulse"');
expect(html).toContain("Blood pressure");
expect(html).toContain("Pulse");
expect(html).toContain("last 30 days");
});
it("renders the sample-count suffix when counts are supplied", () => {
const html = render(
<SourceChips
provenance={{
windows: ["last7days"],
metrics: ["bp"],
counts: { bp: 7 },
}}
/>,
);
expect(html).toContain("n=7");
});
it("uses full semantic text color on source labels", () => {
const html = render(
<SourceChips
provenance={{
windows: ["last7days"],
metrics: ["bp"],
counts: { bp: 7 },
}}
/>,
);
const classNames = [
...classesForSlot(html, "coach-source-chip"),
...classesForSlot(html, "coach-source-window"),
...classesForSlot(html, "coach-source-count"),
];
expect(classNames).toHaveLength(3);
for (const className of classNames) {
expect(className.split(/\s+/)).toContain("text-info");
expect(className).not.toMatch(/\b(?:opacity-\d+|text-\S+\/\d+)\b/);
}
});
it("does not render n=0 chips for zero counts", () => {
const html = render(
<SourceChips
provenance={{
windows: ["last7days"],
metrics: ["bp"],
counts: { bp: 0 },
}}
/>,
);
expect(html).not.toContain("n=0");
});
it("uses German labels when locale is 'de'", () => {
const html = render(
<SourceChips provenance={{ windows: ["last30days"], metrics: ["bp"] }} />,
"de",
);
expect(html).toContain("Blutdruck");
expect(html).toContain("letzte 30 Tage");
});
it("renders a metric chip without window when windows are empty", () => {
const html = render(
<SourceChips provenance={{ windows: [], metrics: ["general"] }} />,
);
expect(html).toContain('data-metric="general"');
expect(html).toContain("General");
expect(html).not.toContain("last 30 days");
});
});
@@ -124,10 +124,53 @@ describe("<SourcesRail>", () => {
expect(html).toMatch(/data-source="cardio"[^>]*data-active="false"/);
});
it("shows the per-cluster member-count hint", () => {
it("shows the per-cluster member-count hint, singular for one", () => {
const html = render(<SourcesRail />);
// cardio expands to 9 sources, mood to 1 — both surface a count.
expect(html).toContain("9 metrics");
expect(html).toContain("1 metrics");
expect(html).toContain("1 metric<");
expect(html).not.toContain("1 metrics");
});
it("never fades a switched-off row (contrast)", () => {
// `opacity-60` over muted text fell below WCAG AA in both themes. The
// off state is the dashed frame and the switch, the text keeps its
// tokens.
const html = render(<SourcesRail />);
const rows = html.match(/<li[^>]*data-slot="coach-sources-row"[^>]*>/g);
expect(rows?.length).toBe(10);
for (const row of rows ?? []) expect(row).not.toMatch(/opacity/);
expect(html).toMatch(
/data-source="glucose"[^>]*data-active="false"[^>]*class="[^"]*border-dashed/,
);
});
it("names the lookback a limit and states what the default reads", () => {
const html = render(<SourcesRail />);
expect(html).toContain("How far back the Coach can look");
// `allTime` is the default: the sentence carries the numbers the
// readers use (`history-reach.ts`).
expect(html).toContain(
"Everything you recorded: 12 months in daily detail, older readings as monthly values up to 10 years back, lab results from the last 12 months.",
);
expect(html).not.toContain("stay visible");
});
it("states the limit and what stays visible under a bounded lookback", () => {
prefsState.data = { ...DEFAULT_COACH_PREFS, defaultWindow: "last90days" };
const html = render(<SourcesRail />);
expect(html).toContain(
"The Coach reads nothing older than 90 days, in any answer.",
);
expect(html).toContain("stay visible");
expect(html).toContain('aria-describedby="coach-sources-window-detail"');
});
it("offers the twelve-month limit", () => {
prefsState.data = { ...DEFAULT_COACH_PREFS, defaultWindow: "lastYear" };
const html = render(<SourcesRail />);
expect(html).toContain(
"The Coach reads nothing older than 12 months, in any answer.",
);
});
});
@@ -10,7 +10,9 @@ import type { CoachStep } from "@/lib/ai/coach/types";
import {
CoachTurnStepList,
CoachTurnSteps,
CoachTurnStepsPanel,
countSources,
legacyAreaLabels,
currentStep,
describeStep,
nextAnnouncement,
@@ -264,3 +266,86 @@ describe("<CoachTurnStepList>", () => {
expect(text(stored)).toBe(text(live));
});
});
describe("the open list: method and the tables the answer read", () => {
const METHOD = {
entries: [],
text: "Blood pressure, last 30 days, 9 readings, daily averages.",
};
it("ends with the method line and the data-used slot, after the steps", () => {
const html = render(
<CoachTurnStepsPanel
steps={[BP]}
active={false}
areaLabels={[]}
method={METHOD}
dataUsed={<div data-slot="coach-data-used">tables</div>}
/>,
);
const steps = html.indexOf('data-slot="coach-turn-steps-list"');
const method = html.indexOf('data-slot="coach-method-line"');
const dataUsed = html.indexOf('data-slot="coach-data-used"');
expect(steps).toBeGreaterThan(-1);
expect(method).toBeGreaterThan(steps);
expect(dataUsed).toBeGreaterThan(method);
expect(html).toContain(METHOD.text);
});
it("holds the method back while the turn runs", () => {
const html = render(
<CoachTurnStepsPanel
steps={[LABS_RUNNING]}
active
areaLabels={[]}
method={METHOD}
dataUsed={<div data-slot="coach-data-used" />}
/>,
);
expect(html).not.toContain("coach-method-line");
expect(html).not.toContain("coach-data-used");
});
it("lists an older message's areas, without counts", () => {
const html = render(
<CoachTurnStepsPanel
steps={[]}
active={false}
areaLabels={["Blood pressure", "Sleep"]}
/>,
);
expect(html).toContain('data-slot="coach-turn-areas"');
expect((html.match(/data-slot="coach-turn-area"/g) ?? []).length).toBe(2);
expect(html).not.toMatch(/\d+ readings/);
});
it("shows a header for an answer that has only a method", () => {
const html = render(
<CoachTurnSteps steps={[]} active={false} method={METHOD} />,
);
expect(html).toContain("What the Coach looked at");
});
});
describe("legacyAreaLabels", () => {
const { t } = translators("en");
it("names the known areas and drops unknown tokens and general", () => {
expect(
legacyAreaLabels(
["bp", "bloodPressure" as never, "general", "sleep", "bp"],
t,
),
).toEqual(["Blood pressure", "Sleep"]);
});
it("folds an older message into a count of areas", () => {
const html = render(
<CoachTurnSteps steps={[]} active={false} areas={["bp", "sleep"]} />,
);
expect(html).toContain("Looked at 2 areas");
expect(
render(<CoachTurnSteps steps={[]} active={false} areas={["bp"]} />, "de"),
).toContain("1 Bereich angesehen");
});
});
@@ -1,63 +1,39 @@
"use client";
import { memo, useCallback, useEffect, useId, useRef, useState } from "react";
import { memo } from "react";
import Link from "next/link";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
BookmarkPlus,
Bot,
Check,
ChevronRight,
Clock,
Copy,
Info,
Loader2,
RotateCcw,
Sparkles,
ThumbsDown,
ThumbsUp,
User,
} from "lucide-react";
import { Bot, Info, RotateCcw, Sparkles, User } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { ApiError, apiPost } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import { useTranslations } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth";
import { ABOUT_ME_FIELD_MAX_CHARS } from "@/lib/validations/about-me";
import {
parseChartTokens,
stripChartTokens,
tokenToMetric,
type ChartToken,
} from "@/lib/insights/chart-tokens";
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import { ProseBlocks } from "@/components/insights/prose-blocks";
import { SourceChips } from "./source-chips";
import { ReminderSuggestionCard } from "./reminder-suggestion-card";
import { SuggestedActionCard } from "./suggested-action-card";
import { StreamedProse } from "./streamed-prose";
import { MessageTokenFooter } from "./message-token-footer";
import { CoachTurnSteps } from "./turn-steps";
import { CoachResults, countResultsInSection } from "./coach-results";
import { CoachMethodLine } from "./method-line";
import {
COACH_ICON_BUTTON,
ReadAloudButton,
useClipboardSupported,
} from "./read-aloud";
import type { CoachProvenanceMetric } from "@/lib/ai/coach/types";
import { CoachFollowUpChips } from "./follow-up-chips";
import { AssistantMessageActions, UserMessageActions } from "./message-actions";
import type {
CoachFollowUp,
CoachProvenanceMetric,
} from "@/lib/ai/coach/types";
/**
* Per-message bubble renderer for the Coach thread, split out of
* `message-thread.tsx` (v1.28.26 file-size split — pure code motion, no
* behaviour change). Owns the user + assistant `ChatBubble` (evidence
* disclosure, accompanying charts, action row), the per-message action
* buttons (copy / feedback / try-again / timestamp / remember), the
* typing indicator, the chart-token grounding selector, and the
* `message-thread.tsx` (v1.28.26 file-size split). Owns the user + assistant
* `ChatBubble` (steps, accompanying charts, the action row from
* `message-actions.tsx`, and the follow-up chips under the latest answer),
* the typing indicator, the chart-token grounding selector, and the
* error-code → i18n resolver. `message-thread.tsx` re-exports the
* previously public names so external call sites are unchanged.
*/
@@ -263,122 +239,6 @@ function coachChartTitle(
return resolved === key ? metric.replace(/_/g, " ").toLowerCase() : resolved;
}
/**
* v1.22 — copy a message to the clipboard. Assistant prose is copied through
* `stripChartTokens` (the same text the bubble shows) so inline chart tokens
* are never pasted; user text is verbatim. A brief check-mark + toast confirm
* the copy. Hidden when the Clipboard API is unavailable (insecure context).
*/
function CopyMessageButton({
content,
strip,
}: {
content: string;
strip: boolean;
}) {
const { t } = useTranslations();
const supported = useClipboardSupported();
const [copied, setCopied] = useState(false);
const handle = useCallback(async () => {
const text = strip ? stripChartTokens(content) : content;
try {
await navigator.clipboard.writeText(text);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch {
toast.error(t("insights.coach.copyMessageError"));
}
}, [content, strip, t]);
// Absent on insecure-context (plain-HTTP) self-hosts where the Clipboard API
// is undefined — render nothing rather than error-toast on tap.
if (!supported) return null;
const label = t("insights.coach.copyMessage");
return (
<button
type="button"
data-slot="coach-copy-message"
onClick={handle}
aria-label={label}
title={label}
className={COACH_ICON_BUTTON}
>
{copied ? (
<Check className="text-success size-3.5" aria-hidden="true" />
) : (
<Copy className="size-3.5" aria-hidden="true" />
)}
</button>
);
}
/**
* v1.22 — "Try again": re-run the user turn that produced this assistant
* reply so the user can regenerate an unsatisfying answer. The thread hands
* down the preceding user message; the surface resubmits it as a fresh turn.
*/
function TryAgainButton({ onRegenerate }: { onRegenerate: () => void }) {
const { t } = useTranslations();
const label = t("insights.coach.regenerate");
return (
<button
type="button"
data-slot="coach-try-again"
onClick={onRegenerate}
aria-label={label}
title={label}
className={COACH_ICON_BUTTON}
>
<RotateCcw className="size-3.5" aria-hidden="true" />
</button>
);
}
/**
* v1.22 (W5) — hover/tap timestamp for a persisted message bubble. A small,
* calm clock affordance; the locale-aware date + time surfaces in a tooltip
* on hover or keyboard focus (desktop) and on tap (mobile, toggled state).
* Pure CSS + one boolean — no portal, no Radix dependency, SSR-safe.
*/
function BubbleTimestamp({
iso,
align = "start",
}: {
iso: string;
align?: "start" | "end";
}) {
const { t } = useTranslations();
const formatters = useFormatters();
const [open, setOpen] = useState(false);
const label = formatters.dateTime(iso);
return (
<span className="relative inline-flex shrink-0">
<button
type="button"
data-slot="coach-bubble-timestamp"
aria-label={t("insights.coach.messageTimeLabel", { time: label })}
onClick={() => setOpen((o) => !o)}
onBlur={() => setOpen(false)}
className="peer text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex size-11 items-center justify-center rounded outline-none focus-visible:ring-2 sm:size-8"
>
<Clock className="size-3" aria-hidden="true" />
</button>
<span
role="tooltip"
className={cn(
"bg-popover text-popover-foreground border-border pointer-events-none absolute bottom-full z-50 mb-1",
"rounded-md border px-2 py-1 text-xs whitespace-nowrap shadow-md",
"opacity-0 transition-opacity duration-100 peer-hover:opacity-100 peer-focus-visible:opacity-100",
"motion-reduce:transition-none",
open && "opacity-100",
align === "end" ? "right-0" : "left-0",
)}
>
{label}
</span>
</span>
);
}
interface ChatBubbleProps {
role: "user" | "assistant";
content: string;
@@ -447,6 +307,29 @@ interface ChatBubbleProps {
* / refusal turns and when the surface supplies no regenerate handler.
*/
onRegenerate?: () => void;
/**
* The follow-up chips, handed only to the latest assistant turn: the
* offering message's id and its chips. They render in the answer's own
* column, after the action row.
*/
followUps?: FollowUpOffer | null;
/** True while another turn is in flight; the chips step aside. */
followUpsDisabled?: boolean;
onFollowUp?: (followUp: CoachFollowUp, messageId: string) => void;
}
/** The chips one assistant message offers. */
export interface FollowUpOffer {
messageId: string;
followUps: CoachFollowUp[];
}
function sameFollowUpOffer(
a: FollowUpOffer | null | undefined,
b: FollowUpOffer | null | undefined,
): boolean {
if (!a || !b) return !a === !b;
return a.messageId === b.messageId && a.followUps === b.followUps;
}
/**
@@ -465,6 +348,11 @@ interface ChatBubbleProps {
* thread), so it is compared by PRESENCE, not identity. Result: only the
* streaming bubble (whose `content`/`streaming` actually change) re-renders.
*
* The follow-up offer is compared by its message id and chip array (the
* thread builds a fresh wrapper object per render, the array itself is the
* provenance's or the stream's and stays put), and `onFollowUp` by identity:
* without these the memoised bubble would keep showing stale chips.
*
* Exported so the contract is unit-testable without standing up the thread.
*/
export function areChatBubblePropsEqual(
@@ -489,6 +377,9 @@ export function areChatBubblePropsEqual(
prev.results === next.results &&
prev.conversationId === next.conversationId &&
prev.usage === next.usage &&
sameFollowUpOffer(prev.followUps, next.followUps) &&
prev.followUpsDisabled === next.followUpsDisabled &&
prev.onFollowUp === next.onFollowUp &&
// onRegenerate is a per-render closure — compare only whether it is present.
(prev.onRegenerate === undefined) === (next.onRegenerate === undefined)
);
@@ -513,19 +404,12 @@ function ChatBubbleImpl({
model,
createdAt,
onRegenerate,
followUps,
followUpsDisabled,
onFollowUp,
}: ChatBubbleProps) {
const { t } = useTranslations();
const { user } = useAuth();
// v1.4.27 B7 / L3 — pair the evidence `<details>` and its disclosed
// list explicitly so screen-readers announce the panel relationship.
const evidencePanelId = useId();
// v1.4.27 MB3 / CF-32 — track the disclosure state in React so the
// summary can carry an accurate `aria-expanded`. Native `<details>`
// reflects its open state via the `open` attribute, but that does
// not surface as `aria-expanded` on the summary by default; screen
// readers still need the explicit attribute to announce the panel
// as expanded vs collapsed.
const [evidenceOpen, setEvidenceOpen] = useState(false);
if (role === "user") {
// v1.5.5 — pull the user's self-hosted avatar so the user
// bubble matches the Coach avatar in size and visual weight.
@@ -545,8 +429,7 @@ function ChatBubbleImpl({
// Budget the avatar column (size-8 + gap-2.5 ≈ 2.625rem) out
// of the 80% cap so the bubble + avatar together never
// overflow a comfortable width on a narrow phone.
// `group/user-bubble` scopes the remember control's
// hover/focus reveal (see `RememberUserMessage`).
// `group/user-bubble` scopes the action row's hover/focus reveal.
"group/user-bubble flex max-w-[calc(80%-2.625rem)] flex-col items-end gap-1",
)}
>
@@ -562,33 +445,14 @@ function ChatBubbleImpl({
is verbatim: no chart-token strip, no Learn linkify. */}
<ProseBlocks text={content} strip={false} linkify={false} />
</div>
{/* v1.22 — per-message action row: Copy, then the timestamp
trailing. Muted until the bubble is hovered / focused on pointer
devices; always visible on touch (no hover to reveal it). */}
<div
data-slot="coach-bubble-actions"
className={cn(
"flex items-center justify-end gap-0.5",
"sm:[@media(hover:hover)]:opacity-0",
"sm:[@media(hover:hover)]:group-hover/user-bubble:opacity-100",
"sm:[@media(hover:hover)]:group-focus-within/user-bubble:opacity-100",
"transition-opacity duration-150 motion-reduce:transition-none",
)}
>
<CopyMessageButton content={content} strip={false} />
{createdAt && <BubbleTimestamp iso={createdAt} align="end" />}
</div>
{/* v1.16.8 — explicit remember control. Stating an allergy in
chat used to leave no durable trace unless a narrow
pattern pass happened to match the phrasing; this stores
the message into the editable self-context (Settings → AI)
on one tap, so it rides every future system prompt. Only
persisted messages get the control (an optimistic bubble
has no id yet), and only when the text fits the
self-context field cap. */}
{messageId && content.length <= ABOUT_ME_FIELD_MAX_CHARS && (
<RememberUserMessage content={content} />
)}
{/* One row under the question: copy, remember, time. Remember
stores the message in the self-context (Settings → AI); only a
persisted message that fits that field offers it. */}
<UserMessageActions
content={content}
messageId={messageId}
createdAt={createdAt}
/>
</div>
{avatarUrl ? (
// eslint-disable-next-line @next/next/no-img-element
@@ -671,18 +535,7 @@ function ChatBubbleImpl({
? t("insights.coach.errorProvider")
: null;
const keyValues = metricSource?.keyValues ?? [];
// v1.12.0 — the provenance disclosure surfaces whenever there is any
// grounding to show: the source chips (metrics/windows) and/or the
// raw key-values. `SourceChips` itself returns null when the envelope
// carries neither metric nor window, so we mirror that condition here
// to decide whether the `<details>` shell renders at all.
const hasChips =
!!metricSource &&
((metricSource.metrics?.length ?? 0) > 0 ||
(metricSource.windows?.length ?? 0) > 0);
const method = metricSource?.method ?? null;
const hasEvidence = hasChips || keyValues.length > 0 || method !== null;
// v1.22 (W5) — Coach charts Phase 1. Render an allowlisted, provenance-
// grounded `metric:<TYPE>` chart under a SETTLED assistant turn. Skipped
@@ -696,7 +549,6 @@ function ChatBubbleImpl({
.filter((meta) => meta.displayed)
.map((meta) => meta.source.domain),
);
const hasResults = !inProgress && !errorCode && resultMetas.length > 0;
const dataUsedCount =
!inProgress && !errorCode
? countResultsInSection(resultMetas, "dataUsed")
@@ -705,7 +557,7 @@ function ChatBubbleImpl({
!streaming && !inProgress && !errorCode && providerType !== "refusal"
? selectCoachChartTokens(content, metricSource?.metrics, shownDomains)
: [];
const hasProvenance = hasEvidence || dataUsedCount > 0;
const settled = !inProgress && !errorCode && providerType !== "refusal";
// v1.32.14 — quiet per-message notice: the grounding guard withheld ≥1 figure
// from this reply (each rewritten to the `[…]` elision mark). Shown only on a
@@ -734,20 +586,36 @@ function ChatBubbleImpl({
<Sparkles className="text-background size-3.5" />
)}
</div>
{/* A reply with tables takes the column's full width, so a table or
chart is as wide as the thread allows instead of as wide as the
prose above it. */}
{/* The answer column. Below `sm` it takes the full width beside the
avatar (at 80 % the action row could not stay on one line at
390 px); from `sm` it keeps the 80 % measure. It is always that
wide, whatever the answer's length, so the action row and the
chips never move sideways as a reply streams in; the prose bubble
inside shrink-wraps. A table or chart takes the column's width. */}
<div
className={cn(
"flex max-w-[calc(80%-2.625rem)] flex-col gap-2",
hasResults && "w-full",
)}
data-slot="coach-answer-column"
className="flex w-full max-w-full min-w-0 flex-col items-start gap-2 sm:max-w-[calc(80%-2.625rem)]"
>
{/* v1.39.4 — what the Coach read on this turn: live from the step
frames, restored from the provenance on reload. */}
{/* What the Coach read on this turn, live from the step frames and
restored from the provenance on reload; an older message lists
the areas it drew on. Opened, it also says how the answer was
worked out and holds the tables the answer read. */}
<CoachTurnSteps
steps={steps ?? metricSource?.steps ?? []}
active={!!inProgress}
areas={metricSource?.metrics}
method={inProgress || errorCode ? null : method}
dataUsed={
dataUsedCount > 0 ? (
<CoachResults
conversationId={conversationId ?? null}
messageId={messageId ?? null}
metas={metricSource?.results ?? []}
live={results}
section="dataUsed"
/>
) : null
}
/>
{/* v1.19.1 (C3) — the live turn shows the classic typing animation
(three pulsing dots) while it is still thinking with no prose
@@ -760,8 +628,9 @@ function ChatBubbleImpl({
it swaps to the streamed prose without a layout jump. */}
{(content || safeError || inProgress) && (
<div
data-slot="coach-answer-bubble"
className={cn(
"border-border/60 bg-muted/40 text-foreground",
"border-border/60 bg-muted/40 text-foreground max-w-full",
"rounded-xl rounded-tl-sm border px-3.5 py-2.5",
"text-sm leading-relaxed break-words whitespace-pre-wrap",
)}
@@ -850,114 +719,6 @@ function ChatBubbleImpl({
{t("insights.coach.errorNoProviderAction")}
</Link>
)}
{/* v1.12.0 — collapse the whole provenance block ("what was
included") behind one disclosure, collapsed by default. The
source chips used to render fully expanded above the
evidence `<details>`, so the grounding context was often
taller than the answer itself. Folding the chips + the raw
key-values into a single closed disclosure keeps the reply
the focus and lets the user expand the grounding on demand —
and removes the always-on chip row that duplicated what the
disclosure already names. The disclosure surfaces whenever
there is any provenance to show (chips and/or key-values). */}
{hasProvenance && (
<details
data-slot="coach-evidence"
open={evidenceOpen}
onToggle={(e) =>
setEvidenceOpen((e.target as HTMLDetailsElement).open)
}
// v1.4.27 F14 — always closed by default. The `open`
// attribute was previously tied to a per-user pref that
// surfaced raw values unconditionally; that pref is now
// retired and the disclosure is a true progressive-
// disclosure surface — the user clicks to expand.
//
// v1.4.27 MB3 / CF-32 — the `open` attribute is now
// controlled from local state so the summary's
// `aria-expanded` stays in lock-step. The native disclosure
// semantics (Enter / Space toggle) are preserved.
className={cn(
"border-border/50 bg-muted/30 group rounded-md border",
"px-2.5 py-1.5 text-xs",
)}
>
<summary
data-slot="coach-evidence-summary"
aria-controls={evidencePanelId}
aria-expanded={evidenceOpen}
className={cn(
"text-muted-foreground hover:text-foreground flex cursor-pointer",
"items-center gap-1.5 leading-relaxed",
"marker:hidden [&::-webkit-details-marker]:hidden",
"focus-visible:ring-ring/50 rounded outline-none focus-visible:ring-2",
)}
>
<ChevronRight
aria-hidden="true"
className="size-3 transition-transform group-open:rotate-90"
/>
<span>{t("insights.coach.evidenceLabel")}</span>
</summary>
<div
id={evidencePanelId}
data-slot="coach-evidence-panel"
className="mt-2 flex flex-col gap-2"
>
{/* v1.12.0 — the source chips now live inside the
disclosure so they expand with the rest of the
grounding instead of always painting above the
answer. */}
{hasChips && metricSource && (
<SourceChips provenance={metricSource} />
)}
{keyValues.length > 0 && (
<ul
data-slot="coach-evidence-list"
className="text-foreground flex flex-col gap-1"
>
{keyValues.map((kv, idx) => (
<li
key={`${kv.label}-${idx}`}
data-slot="coach-evidence-row"
className="leading-relaxed"
>
{/* v1.4.25 W5 — `kv.label` (e.g. "avg7 systolic")
was rendered prefixed to every row, repeating
framing the disclosure heading already gives.
Drop the label and lead with the value; the
window stays as a parenthetical tail so the row
still answers "over what timeframe?". */}
<strong className="font-semibold">
{kv.value}
{kv.unit ? ` ${kv.unit}` : ""}
</strong>
{kv.window && (
<span className="text-muted-foreground">
{" "}
({kv.window})
</span>
)}
</li>
))}
</ul>
)}
{/* v1.39.4 — how the answer was worked out, beside the key
values it explains. */}
<CoachMethodLine method={method} />
{/* v1.39.4 — the tables the answer read but did not point at. */}
{dataUsedCount > 0 && (
<CoachResults
conversationId={conversationId ?? null}
messageId={messageId ?? null}
metas={metricSource?.results ?? []}
live={results}
section="dataUsed"
/>
)}
</div>
</details>
)}
{/* v1.18.1 (Workstream C) — one-tap cadence-suggestion action
card. Live from the streaming hook, or restored from the
persisted message provenance on reload. Not shown on
@@ -978,42 +739,29 @@ function ChatBubbleImpl({
suggestedAction ?? metricSource?.suggestedAction ?? null;
return action ? <SuggestedActionCard action={action} /> : null;
})()}
{/* v1.18.9 — quiet per-message token footer. The just-finished
streaming turn reads the `done.usage` envelope; a persisted /
reloaded turn reads the message's own `tokensUsed` + `model`.
Skipped on in-flight, errored, and refusal turns. */}
{!inProgress && !errorCode && providerType !== "refusal" && (
<MessageTokenFooter
{/* One row under the answer: copy, read aloud, try again and the
time on the left; the tokens and model it cost on the right.
Only on a settled reply (not while it streams, not on an error
or a refusal). */}
{settled && content && (
<AssistantMessageActions
content={content}
streaming={!!streaming}
createdAt={createdAt}
onRegenerate={onRegenerate}
tokens={usage?.totalTokens ?? tokensUsed}
model={usage?.model ?? model}
/>
)}
{/* v1.22 — per-message hover action row. Icon-only, muted until the
bubble is hovered / focused on pointer devices; always visible on
touch (no hover to reveal it). Copy + Read-aloud + Good / Bad
feedback + Try-again, with the timestamp trailing. Only settled
persisted assistant turns get the row (skipped for refusals,
errors, in-flight stream bubbles). */}
{!inProgress && !errorCode && providerType !== "refusal" && content && (
<div
data-slot="coach-bubble-actions"
className={cn(
"flex flex-wrap items-center gap-0.5",
"sm:[@media(hover:hover)]:opacity-0",
"sm:[@media(hover:hover)]:group-hover/assistant-bubble:opacity-100",
"sm:[@media(hover:hover)]:group-focus-within/assistant-bubble:opacity-100",
"transition-opacity duration-150 motion-reduce:transition-none",
)}
>
<CopyMessageButton content={content} strip />
{!streaming && <ReadAloudButton content={content} />}
{messageId && <CoachMessageFeedback messageId={messageId} />}
{!streaming && onRegenerate && (
<TryAgainButton onRegenerate={onRegenerate} />
)}
{createdAt && <BubbleTimestamp iso={createdAt} />}
</div>
)}
{/* The follow-up chips of the latest answer, in its own column. */}
{followUps && onFollowUp ? (
<CoachFollowUpChips
followUps={followUps.followUps}
messageId={followUps.messageId}
disabled={!!followUpsDisabled}
onSelect={onFollowUp}
/>
) : null}
</div>
</div>
);
@@ -1028,107 +776,6 @@ function ChatBubbleImpl({
export const ChatBubble = memo(ChatBubbleImpl, areChatBubblePropsEqual);
ChatBubble.displayName = "ChatBubble";
/**
* v1.16.8 — per-message remember control under the user bubble.
*
* One tap stores the message text into the structured self-context via
* `POST /api/coach/about-me/adopt` (no `question` — the server matches
* the target field from the text itself: an allergy statement lands on
* the allergies field, a stated condition on conditions, everything
* else on the coach-focus slot). The stored text is visible and
* editable under Settings → AI and rides every future Coach system
* prompt. Settled states mirror `SelfContextAdoptOffer`: a short
* confirmation replaces the button after an adoption or a server-side
* dedupe; a failure surfaces a toast and the button stays tappable.
*/
function RememberUserMessage({ content }: { content: string }) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [settled, setSettled] = useState<"adopted" | "duplicate" | null>(null);
// On settle the button unmounts while it holds focus, which would
// drop keyboard focus to <body>. The confirmation paragraph takes
// the focus instead (`tabIndex={-1}` + programmatic focus) so the
// reading position survives the swap.
const statusRef = useRef<HTMLParagraphElement | null>(null);
useEffect(() => {
if (settled) statusRef.current?.focus();
}, [settled]);
const remember = useMutation({
mutationFn: async () => {
return apiPost<{ adopted: boolean }>("/api/coach/about-me/adopt", {
answer: content,
});
},
onSuccess: (data) => {
if (data.adopted) {
// The adoption changed the stored self-context — refresh the
// Settings → AI about-me read so it shows the new entry.
void queryClient.invalidateQueries({
queryKey: queryKeys.coachAboutMe(),
});
}
setSettled(data.adopted ? "adopted" : "duplicate");
},
onError: () => {
toast.error(t("insights.coach.rememberMessage.failed"));
},
});
if (settled) {
return (
<p
role="status"
ref={statusRef}
tabIndex={-1}
data-slot="coach-remember-message-done"
className="text-muted-foreground flex items-center gap-1 text-xs outline-none"
>
<Check className="text-success size-3" aria-hidden="true" />
{t(
settled === "adopted"
? "insights.coach.rememberMessage.done"
: "insights.coach.rememberMessage.duplicate",
)}
</p>
);
}
return (
<button
type="button"
data-slot="coach-remember-message"
onClick={() => remember.mutate()}
disabled={remember.isPending}
className={cn(
"text-muted-foreground hover:text-foreground focus-visible:ring-ring/50",
"inline-flex min-h-11 items-center gap-1 rounded px-1.5 py-1 text-xs sm:min-h-9",
"outline-none focus-visible:ring-2 disabled:opacity-50",
// Calmer thread on pointer devices: the control stays invisible
// until its bubble is hovered or holds focus. Touch viewports
// (no hover media) keep it always visible — there is nothing to
// hover. `opacity-0` (not `invisible`) keeps it focusable so
// keyboard users can reach it; focus then reveals it.
"sm:[@media(hover:hover)]:opacity-0",
"sm:[@media(hover:hover)]:group-hover/user-bubble:opacity-100",
"sm:[@media(hover:hover)]:group-focus-within/user-bubble:opacity-100",
"sm:[@media(hover:hover)]:focus-visible:opacity-100",
"transition-opacity duration-150 motion-reduce:transition-none",
)}
>
{remember.isPending ? (
<Loader2
className="size-3 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
) : (
<BookmarkPlus className="size-3" aria-hidden="true" />
)}
{t("insights.coach.rememberMessage.action")}
</button>
);
}
/**
* v1.16.1 — classic chat typing indicator: three dots pulsing in
* sequence inside the assistant bubble, shown only between submit and
@@ -1163,90 +810,3 @@ export function TypingDots({ label }: { label: string }) {
</span>
);
}
interface CoachMessageFeedbackProps {
messageId: string;
}
function CoachMessageFeedback({ messageId }: CoachMessageFeedbackProps) {
const { t } = useTranslations();
const [submittedRating, setSubmittedRating] = useState<
"helpful" | "unhelpful" | null
>(null);
const submit = useMutation({
mutationFn: async (rating: "helpful" | "unhelpful") => {
try {
await apiPost(`/api/insights/chat/messages/${messageId}/feedback`, {
rating,
});
} catch (err) {
// Treat 409 (already_rated) as a successful no-op so the user
// never sees an error toast for double-clicking the same chip.
if (!(err instanceof ApiError && err.status === 409)) {
throw err;
}
}
return rating;
},
onSuccess: (rating) => setSubmittedRating(rating),
// v1.16.4 — a failed rating used to fail silently; the chips stayed
// tappable with no signal that nothing was recorded.
onError: () => {
toast.error(t("insights.coach.feedbackError"));
},
});
// v1.22 — icon-only feedback in the per-message action row. After a rating
// lands, the chosen thumb stays visible in its confirming colour (the other
// is dropped) with an `sr-only` thanks so the signal is legible without a
// text caption breaking the icon row.
if (submittedRating) {
return (
<span
data-slot="coach-message-feedback-thanks"
role="status"
className="inline-flex items-center"
>
<span className="sr-only">{t("insights.coach.feedbackThanks")}</span>
{submittedRating === "helpful" ? (
<ThumbsUp className="text-success size-3.5" aria-hidden="true" />
) : (
<ThumbsDown className="text-warning size-3.5" aria-hidden="true" />
)}
</span>
);
}
const helpfulLabel = t("insights.coach.feedbackHelpful");
const unhelpfulLabel = t("insights.coach.feedbackUnhelpful");
return (
<div
data-slot="coach-message-feedback"
className="inline-flex items-center"
>
<button
type="button"
data-slot="coach-message-feedback-helpful"
onClick={() => submit.mutate("helpful")}
disabled={submit.isPending}
aria-label={helpfulLabel}
title={helpfulLabel}
className={cn(COACH_ICON_BUTTON, "hover:text-success")}
>
<ThumbsUp className="size-3.5" aria-hidden="true" />
</button>
<button
type="button"
data-slot="coach-message-feedback-unhelpful"
onClick={() => submit.mutate("unhelpful")}
disabled={submit.isPending}
aria-label={unhelpfulLabel}
title={unhelpfulLabel}
className={cn(COACH_ICON_BUTTON, "hover:text-warning")}
>
<ThumbsDown className="size-3.5" aria-hidden="true" />
</button>
</div>
);
}
@@ -1,15 +1,13 @@
"use client";
import { useEffect, useReducer, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
useMutation,
useQueries,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { Paperclip, Plus, Settings, Sparkles } from "lucide-react";
import { Paperclip, Plus, Sparkles } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -56,6 +54,8 @@ import {
type InterleavedThreadItem,
} from "./message-thread";
import { CoachClarificationCard } from "./clarification-card";
import { CoachSettingsOverlay } from "./coach-settings-overlay";
import { ConversationsPanel } from "./conversations-panel";
import { MobileRailTray } from "./mobile-rail-tray";
import { SelfContextAdoptOffer } from "./self-context-adopt-offer";
import { SourcesRail } from "./sources-rail";
@@ -260,6 +260,23 @@ export interface CoachConversationProps {
* the surface-toggle preserves scope. The page omits it.
*/
registerConversationIdGetter?: (getter: () => string | null) => void;
/**
* Page surface only: `/coach?settings=data` (the link from Settings →
* Coach) opens the settings gear on "What I can see". Read once, on mount.
*/
openSettingsOnData?: boolean;
}
/**
* The page's URL for a conversation: `/coach?c=<id>`, or bare `/coach` for a
* new chat. Written with `history.replaceState` (the App Router keeps
* `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
? `/coach?c=${encodeURIComponent(conversationId)}`
: "/coach";
}
export function CoachConversation({
@@ -280,9 +297,9 @@ export function CoachConversation({
initialDocumentId,
initialWorkoutId,
registerConversationIdGetter,
openSettingsOnData = false,
}: CoachConversationProps) {
const { t } = useTranslations();
const router = useRouter();
const [currentConversationId, setCurrentConversationId] = useState<
string | null
@@ -292,6 +309,7 @@ export function CoachConversation({
);
const [historyTrayOpen, setHistoryTrayOpen] = useState(false);
const [sourcesTrayOpen, setSourcesTrayOpen] = useState(false);
const [drawerSettingsOpen, setDrawerSettingsOpen] = useState(false);
const [inputValue, setInputValue] = useResettableValue(prefill ?? "");
// v1.16.4 — self-context backflow: `pendingAdopt` raises a quiet
// offer to fold a clarifying-question answer back into the
@@ -354,9 +372,19 @@ export function CoachConversation({
const attachDoc = useAttachCoachDocument(currentConversationId ?? "");
const detachDoc = useDetachCoachDocument(currentConversationId ?? "");
// Page surface: keep `?c=` in step with the open thread, so a reload (or
// 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);
if (window.location.pathname + window.location.search === href) return;
window.history.replaceState(window.history.state, "", href);
}
const send = useSendCoachMessage({
onDone: (resolvedId) => {
setCurrentConversationId(resolvedId);
syncPageUrl(resolvedId);
},
});
@@ -791,6 +819,27 @@ export function CoachConversation({
setActiveWorkoutId(null);
dispatchGuided({ type: "RESET" });
send.reset();
syncPageUrl(null);
}
// Open a stored conversation. Shared by the page's panel and the drawer's
// history tray, so a switch resets the same state on both surfaces.
function selectConversation(id: string) {
setCurrentConversationId(id);
setHistoryTrayOpen(false);
setPendingAdopt(null);
// v1.29.x (S7) — the selected thread's own `fenced` flag + `attachments`
// are authoritative; drop any staged `?doc=` seed so a pending attachment
// can't leak across a thread switch.
setPendingAttachmentIds([]);
dispatchGuided({ type: "RESET" });
// Drop the finished stream's trailing state, as `handleNewChat` does.
// Without it the previous thread's last assistant or error bubble renders
// appended to the newly selected thread — `streamingActive` stays true
// because that messageId is absent from the new thread's messages —
// until the next send.
send.reset();
syncPageUrl(id);
}
const title = conversation?.title ?? t("insights.coach.newChat");
@@ -840,10 +889,6 @@ export function CoachConversation({
// forking any composer logic (dictation, auto-grow, send/stop, the
// guided-question placeholder all behave identically in both spots).
//
// v1.18.11 (W11) — on the page surface the composer is the control hub:
// it grows a leading `+` actions menu (new chat + open conversations) and
// a settings deep-link. The drawer keeps its own header for those, so the
// hub is page-only.
// v1.29.x (S7) — the composer with its attachment chrome: the pills row + the
// "still indexing" hint stack directly above the input, so they travel with
// the composer into BOTH the new-chat hero and the docked conversation view.
@@ -894,9 +939,6 @@ export function CoachConversation({
? t("insights.coach.guided.answerPlaceholder")
: undefined
}
showHub={surface === "page"}
onNewChat={handleNewChat}
onOpenHistory={() => router.push("/coach/conversations")}
attachEnabled={attachEnabled}
onPickFromVault={() => setPickerOpen(true)}
onUploadNew={handleUploadNew}
@@ -1140,50 +1182,56 @@ export function CoachConversation({
</div>
) : null;
// v1.18.11 (W11) — the PAGE surface drops the top header bar and the
// rail-tray strip entirely. The composer is the single control hub; the
// thread (`[&>*]:max-w-2xl` inner gutter) and the docked composer
// (`mx-auto max-w-2xl`) share ONE centred, max-width-capped column so the
// composer never changes width between the new-chat hero and an active
// conversation. The drawer surface keeps its own header + body chrome
// (handled below) untouched.
// The PAGE surface has no header bar of its own. Conversations, New chat
// and the settings gear live in the conversations panel on the right; its
// toggle sits in the app's top bar. The drawer surface keeps its own header
// + body chrome (handled below).
if (surface === "page") {
return (
<div
data-slot="coach-conversation"
data-variant={surface}
className={cn("flex min-h-0 flex-1 flex-col", className)}
className={cn("flex min-h-0 flex-1 flex-row", className)}
>
{docScopeBanner}
{!heroActive ? <h1 className="sr-only">{title}</h1> : null}
{/* v1.21.4 (A) — the page-toolbar gear was removed; Settings now lives
in the composer's `+` actions menu alongside New chat and
Conversations, keeping the page chrome to the composer alone. */}
{heroActive ? (
<CoachHero composer={composerNode} scopeHint={scopeHint} />
) : (
<>
<div className="flex min-h-0 flex-1 flex-col">
<MessageThread
conversation={conversation ?? null}
streaming={send.streaming}
optimisticUser={send.optimisticUser}
interleaved={interleaved}
onRegenerate={handleRegenerate}
onFollowUp={handleFollowUp}
/>
</div>
{/* Docked composer — the SAME centred, capped column as the
thread (and the hero composer), so the width is constant
across the new-chat → conversation transition. */}
<div
data-slot="coach-page-composer"
className="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>
</div>
</>
)}
{/* The conversation column. Its thread and composer keep one centred,
capped column (`max-w-2xl`) across the new-chat hero and an active
conversation, and that width does not change when the panel
beside it opens or closes: only the centring moves. */}
<div
data-slot="coach-page-main"
className="flex min-h-0 min-w-0 flex-1 flex-col"
>
{docScopeBanner}
{!heroActive ? <h1 className="sr-only">{title}</h1> : null}
{heroActive ? (
<CoachHero composer={composerNode} scopeHint={scopeHint} />
) : (
<>
<div className="flex min-h-0 flex-1 flex-col">
<MessageThread
conversation={conversation ?? null}
streaming={send.streaming}
optimisticUser={send.optimisticUser}
interleaved={interleaved}
onRegenerate={handleRegenerate}
onFollowUp={handleFollowUp}
/>
</div>
<div
data-slot="coach-page-composer"
className="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>
</div>
</>
)}
</div>
<ConversationsPanel
activeId={currentConversationId}
onSelect={selectConversation}
onNewChat={handleNewChat}
openSettingsOnData={openSettingsOnData}
/>
{attachEnabled ? (
<AttachmentPicker
open={pickerOpen}
@@ -1249,24 +1297,13 @@ export function CoachConversation({
>
<Plus className="size-4" aria-hidden="true" />
</Button>
{/* v1.16.1 — the Coach preferences moved into Settings → AI
(one place for model + behaviour). The header keeps a gear
that deep-links there instead of opening an in-chat sheet. */}
<Button
asChild
variant="ghost"
size="icon"
data-slot="coach-settings"
className="text-muted-foreground hover:text-foreground size-11 shrink-0"
>
<Link
href="/settings/ai"
aria-label={t("insights.coach.settingsAriaLabel")}
title={t("insights.coach.settingsAriaLabel")}
>
<Settings className="size-4" aria-hidden="true" />
</Link>
</Button>
{/* The same quick settings the page's panel opens: model and what
the Coach may read. Everything else stays in Settings → AI. */}
<CoachSettingsOverlay
open={drawerSettingsOpen}
onOpenChange={setDrawerSettingsOpen}
className="sm:size-11"
/>
{trailingHeaderActions}
</header>
@@ -1299,22 +1336,7 @@ export function CoachConversation({
historyRail={
<HistoryRail
activeId={currentConversationId}
onSelect={(id) => {
setCurrentConversationId(id);
setHistoryTrayOpen(false);
setPendingAdopt(null);
// v1.29.x (S7) — the selected thread's own `fenced` flag +
// `attachments` are now authoritative; drop any staged `?doc=`
// seed so a pending attachment can't leak across a thread switch.
setPendingAttachmentIds([]);
dispatchGuided({ type: "RESET" });
// Drop the finished stream's trailing state, as `handleNewChat`
// already does. Without it the previous thread's last assistant
// or error bubble renders appended to the newly selected thread
// — `streamingActive` stays true because that messageId is
// absent from the new thread's messages — until the next send.
send.reset();
}}
onSelect={selectConversation}
/>
}
sourcesOpen={sourcesTrayOpen}
@@ -7,17 +7,12 @@ import {
useEffect,
useRef,
} from "react";
import Link from "next/link";
import {
FolderOpen,
Loader2,
MessagesSquare,
Paperclip,
Plus,
Send,
Settings,
Square,
Target,
Upload,
} from "lucide-react";
@@ -55,10 +50,8 @@ import { COACH_COMPOSER_ID } from "./composer-focus";
* the guard stack (system prompt, prose-grounding check, outbound
* refusal), not a rendered disclaimer line.
*
* v1.18.11 (W11): the composer is the conversation's control hub on the
* full-page Coach surface. When `showHub` is set it includes a `+` actions menu
* for a new chat or conversation history. The drawer omits `showHub` because
* its header already carries those actions.
* New chat, the conversation list and the settings gear live in the Coach
* page's conversations panel (and the drawer's header), not in the composer.
*
* Voice input was removed after repeated browser and permission failures made
* the control unreliable. The composer now exposes only actions that work
@@ -95,28 +88,12 @@ export interface CoachInputProps {
* while a question is live. Falls back to the stock composer copy.
*/
placeholder?: string;
/**
* v1.18.11 — mount the control-hub action row (leading `+` menu +
* settings link) inside the composer card. The page surface sets this;
* the drawer leaves it off and keeps the single-row composer.
*/
showHub?: boolean;
/**
* v1.18.11 — start a fresh conversation. Wired into the `+` actions
* menu. Required when `showHub` is set.
*/
onNewChat?: () => void;
/**
* v1.18.11 — open the left conversation-history drawer. Wired into the
* `+` actions menu. Required when `showHub` is set.
*/
onOpenHistory?: () => void;
/**
* v1.29.x (S7) — mount the document-attach trigger (a leading paperclip menu:
* "Choose from documents" + "Upload new document"). Set by the parent only
* when the `inboundDocuments` module is enabled for the user; hidden entirely
* otherwise. Separate from the `showHub` `+` actions menu — this affordance
* stages fenced document attachments, it does not manage conversations.
* otherwise. It stages fenced document attachments; conversations are managed
* in the page's conversations panel.
*/
attachEnabled?: boolean;
/**
@@ -177,9 +154,6 @@ export function CoachInput({
inputId = COACH_COMPOSER_ID,
autoFocusOnOpen = false,
placeholder,
showHub = false,
onNewChat,
onOpenHistory,
attachEnabled = false,
onPickFromVault,
onUploadNew,
@@ -293,61 +267,10 @@ export function CoachInput({
</Button>
);
// Leading `+` actions menu for the full-page Coach surface. The settings
// shortcut remains in this menu while the dedicated gear stays in the page
// toolbar.
const actionsButton = (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
size="icon"
variant="ghost"
disabled={disabled}
data-slot="coach-input-actions"
aria-label={t("insights.coach.actionsMenu")}
title={t("insights.coach.actionsMenu")}
className="text-muted-foreground hover:text-foreground size-11 shrink-0 rounded-xl sm:size-9"
>
<Plus className="size-5 sm:size-4" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" side="top" className="w-52">
<DropdownMenuItem
data-slot="coach-input-action-new-chat"
onSelect={() => onNewChat?.()}
>
<Plus className="size-4" aria-hidden="true" />
{t("insights.coach.newChat")}
</DropdownMenuItem>
<DropdownMenuItem
data-slot="coach-input-action-history"
onSelect={() => onOpenHistory?.()}
>
<MessagesSquare className="size-4" aria-hidden="true" />
{t("insights.coach.historyTitle")}
</DropdownMenuItem>
<DropdownMenuItem asChild data-slot="coach-input-action-plans">
<Link href="/coach/plans">
<Target className="size-4" aria-hidden="true" />
{t("coach.plans.title")}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild data-slot="coach-input-action-settings">
<Link href="/settings/ai">
<Settings className="size-4" aria-hidden="true" />
{t("insights.coach.settings")}
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
// v1.29.x (S7) — the document-attach affordance: a leading paperclip menu
// ("Choose from documents" opens the vault picker; "Upload new document"
// opens the file input, reusing the existing `/api/documents/inbound`
// pipeline). Rendered only when the parent enables it (module-gated). It is a
// SEPARATE control from the `showHub` `+` actions menu — the two never merge.
// pipeline). Rendered only when the parent enables it (module-gated).
const attachButton = attachEnabled ? (
<>
<input
@@ -474,14 +397,8 @@ export function CoachInput({
className="flex shrink-0 items-center gap-1.5"
>
{attachButton}
{showHub ? actionsButton : null}
</div>
<div
data-slot={showHub ? "coach-input-hub" : undefined}
className="flex shrink-0 items-center gap-1.5"
>
{sendButton}
</div>
<div className="flex shrink-0 items-center gap-1.5">{sendButton}</div>
</div>
</div>
</form>
@@ -0,0 +1,420 @@
"use client";
import { useState } from "react";
import { Loader2 } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { ReasoningEffortField } from "@/components/settings/ai/reasoning-effort-field";
import {
ANTHROPIC_MODEL_PRESETS,
LOCAL_MODEL_PRESETS,
OPENAI_MODEL_PRESETS,
uiToLegacyProviderEnum,
type ProviderChainData,
type ProviderType,
type ReasoningEffort,
type UserAIProvider,
} from "@/components/settings/ai/shared";
import { apiGet, apiPatch, apiPut } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import {
aiInputDependentKeys,
invalidateKeys,
queryKeys,
} from "@/lib/query-keys";
/**
* The quick model choice in the Coach's settings overlay: which provider
* answers, which model it uses, and how hard a reasoning model thinks.
*
* It is a shortcut onto the same settings Settings → AI writes, never a second
* copy of them. Keys, base URLs, the Codex sign-in and the order of the
* fallback chain stay there; this reads the same two queries and writes
* through the same two routes:
* - Provider: `PUT /api/insights/provider-chain` with the chosen entry moved
* to the front and every other entry in its current order. The entries'
* reasoning settings are not sent, so the server keeps what it stored.
* - Model and reasoning: `PATCH /api/user/ai-provider`, only where a person
* owns the setting (their own key's provider, or their gateway). The
* operator's shared provider and Codex show their state read-only.
* Every write invalidates the AI input keys, so the capability on
* `/api/auth/me` comes back from the server rather than being guessed here.
*/
/** The configured chain with `provider` first; everything else keeps order. */
export function chainWithFirst(
chain: ProviderChainData["configuredChain"],
provider: ProviderType,
): { providerType: ProviderType; priority: number; enabled: boolean }[] {
const rest = chain.filter((entry) => entry.providerType !== provider);
const ordered = [
{ providerType: provider, enabled: true },
...rest.map((entry) => ({
providerType: entry.providerType,
enabled: entry.enabled,
})),
];
return ordered.map((entry, idx) => ({ ...entry, priority: idx + 1 }));
}
/** The providers a person can switch to: enabled and able to answer. */
export function selectableProviders(
chain: ProviderChainData["configuredChain"],
): ProviderType[] {
return chain
.filter((entry) => entry.enabled && entry.available)
.map((entry) => entry.providerType);
}
/**
* True when a saved switch did not take: the chain the server resolves after
* the save is not led by the provider the person chose. A provider whose key
* stops working between the read and the save lands here.
*/
export function switchDidNotTake(
chosen: ProviderType,
fresh: Pick<ProviderChainData, "activeProvider">,
): boolean {
return fresh.activeProvider !== chosen;
}
/**
* The provider the server answers with when the select has no option for it:
* the operator's central Codex (never a chain entry), or an entry that answers
* without being offered. Shown as a disabled option so the select tells the
* truth instead of showing its first option as active.
*/
export function activeOutsideOptions(
active: string | null,
options: readonly string[],
): string | null {
return active !== null && !options.includes(active) ? active : null;
}
export type ModelControl =
| { kind: "preset"; presets: readonly string[]; value: string | null }
| { kind: "gateway"; value: string | null }
| { kind: "managed" };
const PRESETS: Partial<Record<ProviderType, readonly string[]>> = {
openai: OPENAI_MODEL_PRESETS,
anthropic: ANTHROPIC_MODEL_PRESETS,
local: LOCAL_MODEL_PRESETS,
};
/**
* Which model control the active provider gets. The model column is shared by
* the OpenAI, Anthropic and Local providers and belongs to whichever of them
* the person configured, so the control is offered only for that one; a
* model name chosen for another would be sent to the wrong vendor.
*/
export function modelControlFor(
active: ProviderType | null,
userProvider: UserAIProvider | null | undefined,
): ModelControl {
if (!active || !userProvider) return { kind: "managed" };
if (active === "openai-compatible") {
return { kind: "gateway", value: userProvider.compatModel };
}
const presets = PRESETS[active];
if (presets && userProvider.provider === uiToLegacyProviderEnum(active)) {
return { kind: "preset", presets, value: userProvider.model };
}
return { kind: "managed" };
}
/** The reasoning setting the active provider carries, if any. */
export function reasoningFieldFor(
active: ProviderType | null,
userProvider: UserAIProvider | null | undefined,
): {
field: "localReasoningEffort" | "compatReasoningEffort";
value: ReasoningEffort | null;
} | null {
if (active === "local") {
return {
field: "localReasoningEffort",
value: userProvider?.localReasoningEffort ?? null,
};
}
if (active === "openai-compatible") {
return {
field: "compatReasoningEffort",
value: userProvider?.compatReasoningEffort ?? null,
};
}
return null;
}
export function CoachModelPicker() {
const { t } = useTranslations();
const queryClient = useQueryClient();
const chainQuery = useQuery({
queryKey: queryKeys.insightsProviderChain(),
queryFn: () => apiGet<ProviderChainData>("/api/insights/provider-chain"),
});
const providerQuery = useQuery({
queryKey: queryKeys.userAiProvider(),
queryFn: () => apiGet<UserAIProvider>("/api/user/ai-provider"),
});
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const onSaved = () => {
setError(null);
void invalidateKeys(queryClient, aiInputDependentKeys);
};
const onFailed = (e: unknown) =>
setError(
e instanceof Error && e.message
? e.message
: t("insights.coach.frame.saveFailed"),
);
const switchProvider = useMutation({
mutationFn: async (provider: ProviderType) => {
setNotice(null);
await apiPut("/api/insights/provider-chain", {
chain: chainWithFirst(chainQuery.data?.configuredChain ?? [], provider),
});
// Read back what the server now resolves, so a switch that did not
// take is said rather than shown as the select jumping back.
return apiGet<ProviderChainData>("/api/insights/provider-chain");
},
onSuccess: (fresh, provider) => {
queryClient.setQueryData(queryKeys.insightsProviderChain(), fresh);
onSaved();
if (switchDidNotTake(provider, fresh)) {
setNotice(
t("insights.coach.frame.providerNotActive", {
provider: t(`settings.ai.providerChain.types.${provider}`),
}),
);
}
},
onError: onFailed,
});
const patchProvider = useMutation({
mutationFn: (body: Record<string, unknown>) =>
apiPatch("/api/user/ai-provider", body),
onSuccess: onSaved,
onError: onFailed,
});
const chain = chainQuery.data;
const userProvider = providerQuery.data;
const active = chain?.activeProvider ?? null;
const options = chain ? selectableProviders(chain.configuredChain) : [];
const model = modelControlFor(active, userProvider);
const reasoning = reasoningFieldFor(active, userProvider);
const busy = switchProvider.isPending || patchProvider.isPending;
// The server may answer with `admin-codex`, which the UI's type omits.
const unlisted = activeOutsideOptions(active as string | null, options);
if (chainQuery.isError || providerQuery.isError) {
return (
<div className="flex flex-col items-start gap-2 text-sm">
<p role="alert" className="text-destructive">
{t("common.loadFailed")}
</p>
<Button
variant="outline"
size="sm"
onClick={() => {
void chainQuery.refetch();
void providerQuery.refetch();
}}
>
{t("common.retry")}
</Button>
</div>
);
}
if (!chain || !userProvider) {
return (
<p
role="status"
className="text-muted-foreground flex items-center gap-2 text-sm"
>
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
{t("common.loading")}
</p>
);
}
return (
<div data-slot="coach-model-picker" className="flex flex-col gap-4">
<div>
<Label htmlFor="coach-quick-provider" noColon>
{t("insights.coach.frame.provider")}
</Label>
<NativeSelect
id="coach-quick-provider"
data-slot="coach-quick-provider"
className="mt-1"
value={active ?? ""}
disabled={busy || options.length === 0}
onChange={(e) => {
const next = e.target.value as ProviderType;
if (next && next !== active) switchProvider.mutate(next);
}}
>
{active === null ? (
<option value="" disabled>
{t("insights.coach.frame.noProvider")}
</option>
) : null}
{unlisted ? (
<option value={unlisted} disabled>
{unlisted === "admin-codex"
? t("insights.coach.frame.operatorCodex")
: t(`settings.ai.providerChain.types.${unlisted}`)}
</option>
) : null}
{options.map((p) => (
<option key={p} value={p}>
{t(`settings.ai.providerChain.types.${p}`)}
</option>
))}
</NativeSelect>
{unlisted === "admin-codex" ? (
<p
data-slot="coach-quick-provider-operator"
className="text-muted-foreground mt-1 text-xs"
>
{t("insights.coach.frame.operatorManaged")}
</p>
) : null}
</div>
{model.kind === "preset" ? (
<div>
<Label htmlFor="coach-quick-model" noColon>
{t("settings.ai.modelLabel")}
</Label>
<NativeSelect
id="coach-quick-model"
data-slot="coach-quick-model"
className="mt-1"
value={model.value ?? ""}
disabled={busy}
onChange={(e) =>
patchProvider.mutate({ model: e.target.value || null })
}
>
<option value="">{t("settings.ai.modelOptionDefault")}</option>
{model.value && !model.presets.includes(model.value) ? (
<option value={model.value}>{model.value}</option>
) : null}
{model.presets.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</NativeSelect>
</div>
) : model.kind === "gateway" ? (
<GatewayModelField
key={model.value ?? ""}
value={model.value}
disabled={busy}
onSave={(next) => patchProvider.mutate({ compatModel: next })}
/>
) : (
<p
data-slot="coach-quick-model-managed"
className="text-muted-foreground text-xs"
>
{t("insights.coach.frame.modelManaged")}
</p>
)}
{reasoning ? (
<ReasoningEffortField
id="coach-quick-reasoning"
noColon
value={reasoning.value}
disabled={busy}
onChange={(next) => patchProvider.mutate({ [reasoning.field]: next })}
/>
) : null}
{busy ? (
<p role="status" className="text-muted-foreground text-xs">
{t("insights.coach.frame.saving")}
</p>
) : null}
{notice ? (
<p
role="status"
data-slot="coach-quick-provider-notice"
className="text-muted-foreground text-xs"
>
{notice}
</p>
) : null}
{error ? (
<p role="alert" className="text-destructive text-xs">
{error}
</p>
) : null}
</div>
);
}
/** A gateway names its own models, so its model is free text with a save. */
function GatewayModelField({
value,
disabled,
onSave,
}: {
value: string | null;
disabled: boolean;
onSave: (next: string | null) => void;
}) {
const { t } = useTranslations();
const [draft, setDraft] = useState(value ?? "");
const changed = draft.trim() !== (value ?? "");
return (
<form
className="flex flex-col gap-1"
onSubmit={(e) => {
e.preventDefault();
if (changed) onSave(draft.trim() || null);
}}
>
<Label htmlFor="coach-quick-gateway-model" noColon>
{t("settings.ai.modelLabel")}
</Label>
<div className="flex items-center gap-2">
<Input
id="coach-quick-gateway-model"
data-slot="coach-quick-gateway-model"
value={draft}
onChange={(e) => setDraft(e.target.value)}
disabled={disabled}
className="min-w-0 flex-1"
/>
<Button
type="submit"
size="sm"
variant="outline"
disabled={disabled || !changed}
className="min-h-11 sm:min-h-9"
>
{t("settings.ai.saveCta")}
</Button>
</div>
</form>
);
}
@@ -0,0 +1,76 @@
"use client";
import { useEffect, useRef } from "react";
import Link from "next/link";
import { Cpu } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { CoachModelPicker } from "./coach-model-picker";
import { SourcesRail } from "./sources-rail";
/**
* The content of the Coach's quick settings (see `coach-settings-overlay`):
* the model choice, "What I can see" (the existing sources rail, wired to the
* saved Coach preferences: data areas and how far back the Coach looks) and
* a way on to the full AI settings.
*
* `focusData` lands focus on the "What I can see" section when the body
* mounts, for the `/coach?settings=data` deep link. It runs here rather than
* in the overlay because the body loads on demand and may arrive after the
* popover or sheet has already placed focus.
*/
export const COACH_SETTINGS_DATA_SECTION_ID = "coach-settings-data";
export function CoachSettingsBody({
focusData = false,
}: {
focusData?: boolean;
}) {
const { t } = useTranslations();
const dataRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!focusData) return;
const frame = requestAnimationFrame(() => {
dataRef.current?.focus();
dataRef.current?.scrollIntoView({ block: "start" });
});
return () => cancelAnimationFrame(frame);
}, [focusData]);
return (
<div data-slot="coach-settings-body" className="flex flex-col gap-4">
<section
aria-labelledby="coach-settings-model-heading"
data-slot="coach-settings-model"
className="flex flex-col gap-3"
>
<h3
id="coach-settings-model-heading"
className="text-muted-foreground flex items-center gap-1.5 text-xs font-medium tracking-wide uppercase"
>
<Cpu className="text-muted-foreground size-3.5" aria-hidden="true" />
{t("insights.coach.frame.modelSection")}
</h3>
<CoachModelPicker />
</section>
<section
ref={dataRef}
id={COACH_SETTINGS_DATA_SECTION_ID}
data-slot="coach-settings-data"
tabIndex={-1}
aria-label={t("insights.coach.sourcesTitle")}
className="border-border focus-visible:ring-ring/50 border-t pt-4 outline-none focus-visible:ring-2"
>
<SourcesRail className="h-auto p-0" />
</section>
<div className="border-border border-t pt-4">
<Button asChild variant="outline" size="sm" className="min-h-11">
<Link href="/settings/ai" data-slot="coach-settings-all">
{t("insights.coach.frame.allSettings")}
</Link>
</Button>
</div>
</div>
);
}
@@ -0,0 +1,129 @@
"use client";
import { useRef } from "react";
import dynamic from "next/dynamic";
import { Loader2, Settings } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { useIsMobile } from "@/hooks/use-is-mobile";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
// The body (model picker, the sources rail, their queries) loads when the
// gear is first opened, as one shared chunk, instead of riding along with
// every route that can mount the Coach drawer.
const CoachSettingsBody = dynamic(
() => import("./coach-settings-body").then((m) => m.CoachSettingsBody),
{ ssr: false, loading: () => <BodyLoading /> },
);
function BodyLoading() {
return (
<div className="flex min-h-24 items-center justify-center">
<Loader2
className="text-muted-foreground size-4 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
</div>
);
}
/**
* The Coach's quick settings, behind the gear: which model answers, and what
* the Coach may read.
*
* A popover anchored on the gear from `md` up, a bottom sheet on a phone.
* Both trap focus, close on Escape and hand focus back to the gear. Nothing
* in here does model work; the provider write invalidates the AI input keys
* and the capability comes back from the server.
*
* `focusData` opens it on the "What I can see" section: Settings → Coach
* links here with `/coach?settings=data`.
*/
export interface CoachSettingsOverlayProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Open on the "What I can see" section instead of the top. */
focusData?: boolean;
/** Classes for the gear button, so each header sizes it like its row. */
className?: string;
}
export function CoachSettingsOverlay({
open,
onOpenChange,
focusData = false,
className,
}: CoachSettingsOverlayProps) {
const { t } = useTranslations();
const isPhone = useIsMobile("md");
const gearRef = useRef<HTMLButtonElement>(null);
const gear = (
<Button
ref={gearRef}
type="button"
variant="ghost"
size="icon"
data-slot="coach-settings"
aria-label={t("insights.coach.settingsAriaLabel")}
title={t("insights.coach.settingsAriaLabel")}
aria-haspopup="dialog"
aria-expanded={open}
onClick={isPhone ? () => onOpenChange(true) : undefined}
className={cn(
"text-muted-foreground hover:text-foreground size-11 shrink-0 sm:size-9",
className,
)}
>
<Settings className="size-4" aria-hidden="true" />
</Button>
);
if (isPhone) {
return (
<>
{gear}
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
title={t("insights.coach.settingsAriaLabel")}
onCloseAutoFocus={(event) => {
event.preventDefault();
gearRef.current?.focus();
}}
>
<CoachSettingsBody focusData={focusData} />
</ResponsiveSheet>
</>
);
}
return (
<Popover open={open} onOpenChange={onOpenChange} modal>
<PopoverTrigger asChild>{gear}</PopoverTrigger>
<PopoverContent
align="end"
sideOffset={8}
aria-labelledby="coach-settings-title"
data-slot="coach-settings-popover"
className="flex max-h-[min(36rem,80dvh)] w-[22rem] max-w-[calc(100vw-2rem)] flex-col gap-3 overflow-y-auto overscroll-contain p-4 text-sm"
onOpenAutoFocus={(event) => {
// The body moves focus to its data section once it has loaded.
if (focusData) event.preventDefault();
}}
>
<h2 id="coach-settings-title" className="text-base font-semibold">
{t("insights.coach.settingsAriaLabel")}
</h2>
<CoachSettingsBody focusData={focusData} />
</PopoverContent>
</Popover>
);
}
@@ -25,18 +25,39 @@ export function getConversationRenameKeyAction(
export interface ConversationRenameProps {
id: string;
title: string;
compact?: boolean;
/**
* Controlled mode, for a surface that opens the editor from somewhere else
* (the conversations panel's row menu). When set, the component renders no
* trigger of its own: it shows the form while `editing` is true and reports
* the end of an edit (saved or cancelled) through `onEditingChange`.
*/
editing?: boolean;
onEditingChange?: (editing: boolean) => void;
}
export function ConversationRename({
id,
title,
compact = false,
editing: controlledEditing,
onEditingChange,
}: ConversationRenameProps) {
const { t } = useTranslations();
const rename = useRenameCoachConversation();
const [editing, setEditing] = useState(false);
const controlled = controlledEditing !== undefined;
const [localEditing, setLocalEditing] = useState(false);
const editing = controlled ? controlledEditing : localEditing;
const [draft, setDraft] = useState(title);
// A controlled editor opens from outside, so it seeds its draft from the
// current title each time it opens (render-phase, no effect).
const [seededOpen, setSeededOpen] = useState(false);
if (controlled && editing !== seededOpen) {
setSeededOpen(editing);
if (editing) setDraft(title);
}
function setEditing(next: boolean) {
if (controlled) onEditingChange?.(next);
else setLocalEditing(next);
}
const submittingRef = useRef(false);
function cancel() {
@@ -82,23 +103,27 @@ export function ConversationRename({
const action = getConversationRenameKeyAction(event.key);
if (!action) return;
event.preventDefault();
// Escape belongs to the editor while it is open: the panel and the sheet
// around it must not close on the same key press.
event.stopPropagation();
if (action === "cancel") cancel();
else void save();
}
if (!editing) {
if (controlled) return null;
return (
<Button
type="button"
variant="ghost"
size={compact ? "icon" : "icon-lg"}
size="icon-lg"
onClick={() => {
setDraft(title);
setEditing(true);
}}
aria-label={t("insights.coach.rename.action")}
data-slot="coach-conversation-rename"
className={cn("shrink-0", compact && "size-11")}
className="shrink-0"
>
<Pencil className="size-4" aria-hidden="true" />
</Button>
@@ -115,7 +140,7 @@ export function ConversationRename({
data-slot="coach-conversation-rename-form"
className={cn(
"bg-background absolute inset-y-1 left-1 z-10 flex min-w-0 items-center gap-1",
compact ? "right-14" : "right-16",
controlled ? "right-1" : "right-16",
)}
>
<Input
@@ -0,0 +1,326 @@
"use client";
import { useRef, useState, useSyncExternalStore } from "react";
import Link from "next/link";
import {
PanelRightClose,
PanelRightOpen,
SquarePen,
Target,
X,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetTitle,
} from "@/components/ui/sheet";
import { TopBarActions } from "@/components/layout/top-bar-actions";
import { useCoachPanelOpen } from "@/hooks/use-coach-panel-open";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { CoachSettingsOverlay } from "./coach-settings-overlay";
import { HistoryRail } from "./history-rail";
import { useDeleteCoachConversationWithUndo } from "./use-coach";
/**
* The Coach page's conversations panel, on the right of the thread.
*
* From 1280 px it is a column beside the thread that slides open and shut
* (a 200 ms width transition, none under reduced motion). The thread column
* keeps its width either way; only its centring moves. It is open on a first
* visit and remembers the last choice per device (`useCoachPanelOpen`).
* Below 1280 px it is a sheet from the right that opens when asked and
* closes again when a conversation is picked or a new chat starts.
*
* The toggle sits at the trailing edge of the top bar. The panel header
* carries the title, a link to Plans, the settings gear and, in the sheet,
* a close button. The round New chat button sits at the bottom right.
*
* Keyboard: Escape closes 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.
*/
export const COACH_PANEL_ID = "coach-conversations-panel";
const noSubscription = () => () => {};
/**
* 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
* (sheet) layout, so opening it from the first render flashed the sheet on
* a desktop before the docked panel replaced it.
*/
export function sheetShown(state: {
chosen: boolean;
deepLinkPending: boolean;
hydrated: boolean;
docked: boolean;
}): boolean {
return (
state.chosen || (state.deepLinkPending && state.hydrated && !state.docked)
);
}
export interface ConversationsPanelProps {
activeId: string | null;
onSelect: (id: string) => void;
onNewChat: () => void;
/**
* `/coach?settings=data`: reveal the panel and open the settings gear on
* the "What I can see" section. Read once, on mount.
*/
openSettingsOnData?: boolean;
}
export function ConversationsPanel({
activeId,
onSelect,
onNewChat,
openSettingsOnData = false,
}: ConversationsPanelProps) {
const { t } = useTranslations();
const { docked, open: rememberedOpen, setOpen } = useCoachPanelOpen();
const toggleRef = useRef<HTMLButtonElement>(null);
// Owned here, not by the list, so a pending delete keeps its undo window
// when the sheet (and the list inside it) closes.
const deletion = useDeleteCoachConversationWithUndo();
// A settings deep-link shows the panel without rewriting the remembered
// choice; the next toggle settles it.
const [revealed, setRevealed] = useState(openSettingsOnData);
const hydrated = useSyncExternalStore(
noSubscription,
() => true,
() => false,
);
const [deepLinkPending, setDeepLinkPending] = useState(openSettingsOnData);
const [sheetChosen, setSheetChosen] = useState(false);
const sheetOpen = sheetShown({
chosen: sheetChosen,
deepLinkPending,
hydrated,
docked,
});
// On a docked viewport the deep link is served by `revealed`; it must not
// open the sheet later if the window narrows.
if (deepLinkPending && hydrated && docked) setDeepLinkPending(false);
const setSheetOpen = (next: boolean) => {
setDeepLinkPending(false);
setSheetChosen(next);
};
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);
}
}
function closeDocked() {
setRevealed(false);
setOpen(false);
toggleRef.current?.focus();
}
function afterPick() {
if (!docked) setSheetOpen(false);
}
const toggleLabel = expanded
? t("insights.coach.frame.hidePanel")
: t("insights.coach.frame.showPanel");
const header = (inSheet: boolean) => (
<div
data-slot="coach-conversations-panel-header"
className="border-border flex h-14 shrink-0 items-center gap-1 border-b pr-2 pl-3"
>
{inSheet ? (
<SheetTitle className="min-w-0 flex-1 truncate text-sm font-semibold">
{t("insights.coach.historyTitle")}
</SheetTitle>
) : (
<h2 className="min-w-0 flex-1 truncate text-sm font-semibold">
{t("insights.coach.historyTitle")}
</h2>
)}
<Button
asChild
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground size-11 shrink-0 sm:size-9"
>
<Link
href="/coach/plans"
data-slot="coach-panel-plans"
aria-label={t("coach.plans.title")}
title={t("coach.plans.title")}
>
<Target className="size-4" aria-hidden="true" />
</Link>
</Button>
<CoachSettingsOverlay
open={settingsOpen}
onOpenChange={(next) => {
setSettingsOpen(next);
if (!next) setSettingsOnData(false);
}}
focusData={settingsOnData}
/>
{inSheet ? (
<SheetClose asChild>
<Button
type="button"
variant="ghost"
size="icon"
data-slot="coach-panel-close"
aria-label={t("common.close")}
title={t("common.close")}
className="text-muted-foreground hover:text-foreground size-11 shrink-0 sm:size-9"
>
<X className="size-4" aria-hidden="true" />
</Button>
</SheetClose>
) : null}
</div>
);
const body = (inSheet: boolean) => (
<>
{header(inSheet)}
<HistoryRail
hideHeading
activeId={activeId}
onSelect={(id) => {
onSelect(id);
afterPick();
}}
deletion={deletion}
onDeleteActive={() => onNewChat()}
onUndoDeleteActive={(id) => onSelect(id)}
className="min-h-0 flex-1"
/>
<Button
type="button"
size="icon"
onClick={() => {
onNewChat();
afterPick();
}}
data-slot="coach-panel-new-chat"
aria-label={t("insights.coach.newChat")}
title={t("insights.coach.newChat")}
className="absolute right-4 bottom-4 size-12 rounded-full shadow-md"
>
<SquarePen className="size-5" aria-hidden="true" />
</Button>
</>
);
return (
<>
<TopBarActions>
<Button
ref={toggleRef}
type="button"
variant="ghost"
size="icon"
onClick={toggle}
aria-controls={COACH_PANEL_ID}
aria-expanded={expanded}
aria-label={toggleLabel}
title={toggleLabel}
data-slot="coach-panel-toggle"
className="text-muted-foreground hover:text-foreground size-11"
>
{expanded ? (
<PanelRightClose className="size-5" aria-hidden="true" />
) : (
<PanelRightOpen className="size-5" aria-hidden="true" />
)}
</Button>
</TopBarActions>
{docked ? (
<aside
id={COACH_PANEL_ID}
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;
event.preventDefault();
closeDocked();
}}
className={cn(
// The width animates; 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 beside it never changes width.
"bg-background relative shrink-0 overflow-hidden",
"transition-[width] duration-200 ease-linear motion-reduce:transition-none",
dockedOpen ? "w-72" : "w-0",
)}
>
<div className="border-border absolute inset-y-0 left-0 flex w-72 flex-col border-l">
{body(false)}
</div>
</aside>
) : (
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<SheetContent
id={COACH_PANEL_ID}
side="right"
showCloseButton={false}
data-slot="coach-conversations-panel"
className="w-[22rem] max-w-[90vw] gap-0 p-0 sm:max-w-[22rem]"
onOpenAutoFocus={(event) => {
// Land on the sheet itself, not its first control: on a phone
// focusing the search field would raise the keyboard over the
// list the person opened the sheet to read.
event.preventDefault();
(event.currentTarget as HTMLElement | null)?.focus();
}}
onCloseAutoFocus={(event) => {
// The toggle lives in the top bar, outside the sheet; hand
// focus back to it explicitly.
event.preventDefault();
toggleRef.current?.focus();
}}
onEscapeKeyDown={(event) => {
// The rename field takes Escape for itself (cancel the edit);
// the sheet stays open. Radix listens in the capture phase, so
// the field's own handler has not run yet when this fires.
const target = event.target as Element | null;
if (
target?.closest?.(
'[data-slot="coach-conversation-rename-form"]',
)
) {
event.preventDefault();
}
}}
>
<SheetDescription className="sr-only">
{t("insights.coach.frame.panelDescription")}
</SheetDescription>
{body(true)}
</SheetContent>
</Sheet>
)}
</>
);
}
@@ -2,7 +2,9 @@
/**
* v1.39.4 — up to three follow-up chips under the latest assistant reply.
* A tap sends the chip's label as the message with
* They sit inside the answer's own column, on the bubble's left edge and at
* the answer's text size, so they read as part of the reply rather than as
* thread chrome. A tap sends the chip's label as the message with
* `followUp: { messageId, id }`; the server resolves what the chip asks for.
*
* The label is the server's rendering in the request locale. It is not
@@ -46,7 +48,7 @@ export function CoachFollowUpChips({
aria-label={t(COACH_FOLLOW_UP_UI_KEYS.groupLabel)}
data-slot="coach-follow-up-chips"
data-message-id={messageId}
className="flex flex-wrap gap-2"
className="flex max-w-full flex-wrap gap-2"
>
{chips.map((chip) => (
<Button
@@ -54,7 +56,10 @@ export function CoachFollowUpChips({
type="button"
variant="outline"
size="sm"
className="min-h-11"
// The answer's own scale (text-sm, regular weight); the 44 px tap
// floor below `sm`, 36 px beside a pointer. A long label wraps
// inside the pill instead of pushing past the column at 390 px.
className="h-auto min-h-11 max-w-full rounded-full px-3 py-1.5 text-left text-sm leading-snug font-normal whitespace-normal sm:min-h-9"
data-follow-up-id={chip.id}
data-follow-up-kind={chip.kind}
onClick={() => {
@@ -1,10 +1,12 @@
"use client";
import { useMemo, useState } from "react";
import { useMemo, useRef, useState } from "react";
import {
Loader2,
MessagesSquare,
MoreHorizontal,
Paperclip,
Pencil,
RotateCw,
Search,
Trash2,
@@ -12,12 +14,19 @@ import {
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { formatRelativeTime } from "@/lib/i18n/relative-time";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useLoadMoreSentinel } from "@/hooks/use-load-more-sentinel";
import type { CoachConversationDTO } from "@/lib/ai/coach/types";
import { groupConversationsByRecency } from "@/lib/insights/coach-conversation-groups";
import { COACH_SCROLLBAR } from "./message-thread";
import { ConversationRename } from "./conversation-rename";
@@ -27,36 +36,46 @@ import {
} from "./use-coach";
/**
* v1.4.20 phase B2b — conversation history rail.
* The Coach's conversation list: search, the full history grouped by recency
* (Today / Yesterday / This week / Earlier), and one row per conversation with
* a "⋯" menu for Rename and Delete.
*
* Lives in the left column of the Coach drawer on `lg+`. Renders the
* caller's FULL conversation history (v1.30.2 — cursor-paginated via
* `useInfiniteQuery`, loaded incrementally as the list scrolls near its
* end), lets the user search by title (server-side, debounced 200 ms),
* select one to resume, and delete with an undo-able confirm.
* It fills the Coach page's conversations panel and the drawer's history
* tray. The history is cursor-paginated (`useInfiniteQuery`, loaded as the
* list scrolls near its end) and searched server-side on the title, debounced
* 200 ms.
*
* Selection / activeId is a controlled prop so the drawer owns the
* authoritative state across the message thread + composer.
* Rename edits the title in place (`ConversationRename`, Enter saves, Escape
* cancels without closing the panel around it) and returns focus to the row's
* menu button. Delete hides the row at once and commits after the undo window
* (`useDeleteCoachConversationWithUndo`); the toast carries the Undo. A
* surface that can unmount the list while a delete is pending (the panel's
* sheet) passes its own `deletion` from a component that stays mounted, so
* closing the sheet does not cut the undo window short.
*
* Empty + loading + error states are all rendered inline — the rail
* never collapses to "0 px" so the layout stays predictable.
* Selection is a controlled prop: the surface owns the active conversation.
* Loading, error and empty states render inline so the list never collapses.
*/
export interface HistoryRailProps {
activeId: string | null;
onSelect: (id: string) => void;
/**
* Optional className passthrough so the parent can override the
* default layout (currently the drawer wraps it in an `<aside>`).
*/
className?: string;
/**
* v1.18.1 — suppress the rail's own `<h3>` "Conversations" label. The
* page surface wraps the rail in an `<aside>` that already renders an
* `<h2>` band heading with the same text, so the inline rail must not
* stack a second identical heading. Defaults to `false` (the mobile
* tray + standalone uses keep the heading).
* Suppress the list's own `<h3>` label when the surface already titles it
* (the conversations panel has its own header).
*/
hideHeading?: boolean;
/**
* The delete-with-undo state, owned by a component that outlives this list.
* Omitted, the list keeps its own.
*/
deletion?: ReturnType<typeof useDeleteCoachConversationWithUndo>;
/**
* Called when the conversation being deleted is the open one, before the
* row hides, so the surface can move off it; `onUndo` puts it back.
*/
onDeleteActive?: (id: string) => void;
onUndoDeleteActive?: (id: string) => void;
}
export function HistoryRail({
@@ -64,12 +83,12 @@ export function HistoryRail({
onSelect,
className,
hideHeading = false,
deletion,
onDeleteActive,
onUndoDeleteActive,
}: HistoryRailProps) {
const { t, locale } = useTranslations();
const { t } = useTranslations();
const [filter, setFilter] = useState<string>("");
// v1.30.2 (QoL H1) — the search box now drives a SERVER-side query
// (title-only substring, see the route doc comment), so debounce the
// keystroke draft rather than re-issuing a request per character.
const debouncedFilter = useDebouncedValue(filter, 200);
const {
conversations,
@@ -80,21 +99,21 @@ export function HistoryRail({
isFetchingNextPage,
fetchNextPage,
} = useCoachConversationHistory({ search: debouncedFilter });
const ownDeletion = useDeleteCoachConversationWithUndo();
const { pendingDeleteIds, requestDelete, undoDelete } =
useDeleteCoachConversationWithUndo();
deletion ?? ownDeletion;
// pendingDeleteIds is a client-only hide filter for the just-armed undo
// window — the server-side search already resolved the rest of `visible`.
// `pendingDeleteIds` hides rows inside the undo window; the server-side
// search already resolved everything else.
const visible = useMemo(
() => conversations.filter((c) => !pendingDeleteIds.has(c.id)),
[conversations, pendingDeleteIds],
);
const groups = useMemo(() => groupConversationsByRecency(visible), [visible]);
const isSearching = debouncedFilter.trim().length > 0;
// A `useState`-backed callback ref (not a plain `useRef`) so the
// container's assignment itself triggers a re-render — the sentinel
// hook's effect depends on `root` and must re-run once the real DOM
// node exists (it's null during the very first render pass).
// A `useState`-backed callback ref so the sentinel hook re-runs once the
// scroll container exists (null on the very first render).
const [listNode, setListNode] = useState<HTMLDivElement | null>(null);
const sentinelRef = useLoadMoreSentinel({
enabled: hasNextPage && !isFetchingNextPage,
@@ -102,18 +121,17 @@ export function HistoryRail({
root: listNode,
});
// v1.30.1 M5 — a single tap hides the row and schedules the real
// delete; the toast's Undo action cancels it within the grace
// window. Replaces the old arm-then-tap-again confirm, which never
// disarmed. If the user just deleted the active thread, the drawer
// is listening to `onSelect` for navigation; we leave it to the
// parent to clear `activeId` when the conversation disappears.
function handleDeleteRequest(id: string) {
const wasActive = id === activeId;
if (wasActive) onDeleteActive?.(id);
requestDelete(id);
toast.success(t("insights.coach.historyDeleted"), {
action: {
label: t("common.undo"),
onClick: () => undoDelete(id),
onClick: () => {
undoDelete(id);
if (wasActive) onUndoDeleteActive?.(id);
},
},
});
}
@@ -121,19 +139,8 @@ export function HistoryRail({
return (
<div
data-slot="coach-history-rail"
// v1.18.1 (W-COACH-UI C1/C3) — a touch more horizontal padding so
// the list rows clear the rail edge and the search field breathes.
// L5 fix (`.planning/audits/2026-07-18-qa-ui.md`) — `gap-2.5 p-3.5`
// was off the `1/1.5/2/3/4/6/8/10` spacing scale.
className={cn("flex h-full min-h-0 flex-col gap-2 p-3", className)}
>
{/* v1.4.33 — promote the rail label from a `<span>` to a real
`<h3>` so the drawer carries a semantic outline on desktop
where the rail is mounted inline (no `SheetTitle` wrapper).
The mobile rail-tray still wraps the rail inside its own
`SheetTitle`, so screen-reader users hear the section twice
when the tray is open — that's intentional Radix behaviour
(the rail still has to stand alone on desktop). */}
{!hideHeading && (
<h3
data-slot="coach-history-rail-heading"
@@ -156,9 +163,8 @@ export function HistoryRail({
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder={t("insights.coach.historySearchPlaceholder")}
aria-label={t("insights.coach.historySearchPlaceholder")}
data-slot="coach-history-search"
// Full-width, comfortable field (matches the standalone
// conversations page) instead of the old thin h-9 strip.
className="h-10 w-full pl-9"
/>
</div>
@@ -166,8 +172,9 @@ export function HistoryRail({
ref={setListNode}
data-slot="coach-history-list"
className={cn(
"-mx-1 flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto px-1",
// v1.18.7 — same thin/rounded/subtle scrollbar as the thread.
// `pb-20` keeps the last row clear of the panel's floating New
// chat button; it pads this list's own scroll area, not `main`.
"-mx-1 flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto overscroll-contain px-1 pb-20",
COACH_SCROLLBAR,
)}
>
@@ -179,8 +186,7 @@ export function HistoryRail({
{t("common.loading")}
</p>
) : isError && visible.length === 0 ? (
// A load failure must not masquerade as "no conversations yet" —
// offer a retry instead of the empty copy.
// A load failure must not read as "no conversations yet".
<div
data-slot="coach-history-error"
className="text-muted-foreground flex flex-col items-start gap-2 px-2 py-3 text-sm"
@@ -191,7 +197,7 @@ export function HistoryRail({
{t("common.retry")}
</Button>
</div>
) : visible.length === 0 ? (
) : groups.length === 0 ? (
<p
data-slot="coach-history-empty"
className="text-muted-foreground px-2 py-3 text-sm leading-relaxed"
@@ -201,77 +207,35 @@ export function HistoryRail({
: t("insights.coach.historyEmpty")}
</p>
) : (
visible.map((c) => {
const isActive = c.id === activeId;
return (
<div
key={c.id}
data-slot="coach-history-item"
data-active={isActive ? "true" : undefined}
className={cn(
"group relative flex items-center gap-1.5 rounded-lg px-2.5 py-2",
"text-sm transition-colors",
isActive
? "bg-primary/15 text-foreground"
: "text-muted-foreground hover:bg-muted/40 hover:text-foreground",
)}
>
<button
type="button"
onClick={() => onSelect(c.id)}
className="min-w-0 flex-1 truncate text-left"
data-slot="coach-history-select"
>
<span className="flex min-w-0 items-center gap-1.5 font-medium">
{/* v1.29.x (S7) — a fenced thread runs on the hardened
fenced endpoint over its attached documents; badge it
with a paperclip (+ count when more than one) so it
reads as distinct in the rail. */}
{c.fenced ? (
<span
className="text-primary inline-flex shrink-0 items-center gap-0.5"
aria-label={t("insights.coach.attach.railBadge")}
>
<Paperclip className="size-3.5" aria-hidden="true" />
{(c.attachments?.length ?? 0) > 1 ? (
<span className="text-2xs font-semibold tabular-nums">
{c.attachments?.length}
</span>
) : null}
</span>
) : null}
<span className="truncate">{c.title}</span>
</span>
<span className="text-muted-foreground block text-xs">
{formatRelativeTime(c.updatedAt, t, locale)}
</span>
</button>
<ConversationRename id={c.id} title={c.title} compact />
{/* v1.4.27 R3d MB2 — drop the hover-only `opacity-0`
reveal: touch surfaces have no hover, so the delete
affordance was invisible on mobile. The button
sits at 44 px always-visible per the WCAG 2.5.5
floor. v1.30.1 M5 — one tap deletes (with an Undo
toast); the old arm/confirm double-tap is gone. */}
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleDeleteRequest(c.id)}
aria-label={t("insights.coach.historyDeleteAria")}
data-slot="coach-history-delete"
className="size-11 shrink-0"
>
<Trash2 className="size-4" aria-hidden="true" />
</Button>
</div>
);
})
groups.map((group) => (
<section
key={group.id}
data-slot="coach-history-group"
data-group={group.id}
aria-label={t(group.labelKey)}
className="flex flex-col gap-1"
>
{/* h3: one step under the panel's h2 title, and a sibling of the
list's own h3 label where that shows (heading order). */}
<h3 className="text-muted-foreground px-2 text-xs font-medium tracking-wide uppercase">
{t(group.labelKey)}
</h3>
<ul className="flex flex-col gap-0.5">
{group.conversations.map((c) => (
<HistoryRow
key={c.id}
conversation={c}
active={c.id === activeId}
onSelect={onSelect}
onDelete={handleDeleteRequest}
/>
))}
</ul>
</section>
))
)}
{/* v1.30.2 (QoL H1) — IntersectionObserver sentinel: scrolling this
div into the rail's own scrollport pulls the next cursor page.
Empty + aria-hidden — the loading row right below it is the
a11y-visible signal. */}
{/* IntersectionObserver sentinel: scrolling it into the list's own
scrollport pulls the next cursor page. */}
{visible.length > 0 && hasNextPage ? (
<div ref={sentinelRef} aria-hidden="true" className="h-px" />
) : null}
@@ -292,3 +256,133 @@ export function HistoryRail({
</div>
);
}
function HistoryRow({
conversation: c,
active,
onSelect,
onDelete,
}: {
conversation: CoachConversationDTO;
active: boolean;
onSelect: (id: string) => void;
onDelete: (id: string) => void;
}) {
const { t } = useTranslations();
const [renaming, setRenaming] = useState(false);
const menuTriggerRef = useRef<HTMLButtonElement>(null);
// Set when the menu closes INTO the rename editor, so the menu does not
// hand focus back to its trigger and steal it from the title field.
const openingRenameRef = useRef(false);
function endRename() {
setRenaming(false);
// Back to the row's menu button, where the edit started.
requestAnimationFrame(() => menuTriggerRef.current?.focus());
}
return (
<li
data-slot="coach-history-item"
data-active={active ? "true" : undefined}
className="group/row relative"
>
<button
type="button"
onClick={() => onSelect(c.id)}
aria-current={active ? "true" : undefined}
title={c.title}
data-slot="coach-history-select"
className={cn(
"flex min-h-11 w-full min-w-0 items-center gap-1.5 rounded-lg py-2 pr-11 pl-2 text-left text-sm transition-colors",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
// The open conversation reads like the open page in the left
// navigation: the same primary wash, so the two rails agree.
active
? "bg-primary/10 text-primary font-medium"
: "text-foreground hover:bg-muted/60",
)}
>
{/* A fenced thread runs on the hardened document endpoint over its
attachments; the paperclip (with a count above one) marks it. */}
{c.fenced ? (
<span
className="text-primary inline-flex shrink-0 items-center gap-0.5"
aria-label={t("insights.coach.attach.railBadge")}
>
<Paperclip className="size-3.5" aria-hidden="true" />
{(c.attachments?.length ?? 0) > 1 ? (
<span className="text-2xs font-semibold tabular-nums">
{c.attachments?.length}
</span>
) : null}
</span>
) : null}
<span className="min-w-0 truncate">{c.title}</span>
</button>
{renaming ? null : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
ref={menuTriggerRef}
type="button"
variant="ghost"
size="icon"
aria-label={t("insights.coach.frame.rowActions", {
title: c.title,
})}
data-slot="coach-history-row-menu"
className={cn(
"text-muted-foreground hover:text-foreground absolute top-1/2 right-0 size-11 -translate-y-1/2 sm:right-1 sm:size-9",
// Pointer devices reveal it on hover or focus; touch shows it
// always, since there is no hover to reveal it with.
"[@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-focus-within/row:opacity-100 [@media(hover:hover)]:group-hover/row:opacity-100 [@media(hover:hover)]:data-[state=open]:opacity-100",
)}
>
<MoreHorizontal className="size-4" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-44"
onCloseAutoFocus={(event) => {
if (openingRenameRef.current) {
openingRenameRef.current = false;
event.preventDefault();
}
}}
>
<DropdownMenuItem
data-slot="coach-history-rename"
onSelect={() => {
openingRenameRef.current = true;
setRenaming(true);
}}
>
<Pencil aria-hidden="true" />
{t("insights.coach.frame.rename")}
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
data-slot="coach-history-delete"
onSelect={() => onDelete(c.id)}
>
<Trash2 aria-hidden="true" />
{t("insights.coach.frame.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
<ConversationRename
id={c.id}
title={c.title}
editing={renaming}
onEditingChange={(next) => {
if (!next) endRename();
}}
/>
</li>
);
}
@@ -0,0 +1,356 @@
"use client";
/**
* The one action row under a Coach message, for both roles.
*
* Assistant: a single line. Left, the icon actions in reading order (copy,
* read aloud, try again) followed by the time the answer was written; right,
* the tokens and model the answer cost, as meta. The right group truncates
* before the row would wrap, so the row is one line at 390 px too.
*
* User: copy, remember, time, flush with the bubble's right edge.
*
* On pointer devices the row stays quiet until its message is hovered or
* holds focus; on touch it is always visible (there is nothing to hover).
* `opacity-0` rather than `invisible` keeps every control focusable, and
* focus reveals the row.
*/
import { useCallback, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
BookmarkCheck,
BookmarkPlus,
Check,
Copy,
Loader2,
RotateCcw,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { resolveIntlLocale } from "@/lib/format-locale";
import type { Formatters } from "@/lib/format-locale";
import { apiPost } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import { ABOUT_ME_FIELD_MAX_CHARS } from "@/lib/validations/about-me";
import {
COACH_ICON_BUTTON,
ReadAloudButton,
useClipboardSupported,
} from "./read-aloud";
type Translate = (
key: string,
params?: Record<string, string | number>,
) => string;
const DAY_MS = 24 * 60 * 60 * 1000;
/** The quiet-until-hover reveal, scoped to the message's own group. */
const REVEAL = {
assistant: cn(
"sm:[@media(hover:hover)]:opacity-0",
"sm:[@media(hover:hover)]:group-hover/assistant-bubble:opacity-100",
"sm:[@media(hover:hover)]:group-focus-within/assistant-bubble:opacity-100",
),
user: cn(
"sm:[@media(hover:hover)]:opacity-0",
"sm:[@media(hover:hover)]:group-hover/user-bubble:opacity-100",
"sm:[@media(hover:hover)]:group-focus-within/user-bubble:opacity-100",
),
} as const;
/**
* The visible time of a message: the clock alone today, "yesterday 14:32"
* the day before, and the short date with the clock further back. Days are
* compared in the reader's display zone (the formatters carry it). Pure, so
* the three tiers are pinned by a unit test.
*/
export function messageTimeText(
iso: string,
now: number,
formatters: Pick<Formatters, "dateShortSmart" | "time">,
t: Translate,
): string {
const day = formatters.dateShortSmart(iso);
const time = formatters.time(iso);
if (day === formatters.dateShortSmart(new Date(now))) return time;
if (day === formatters.dateShortSmart(new Date(now - DAY_MS))) {
return t("insights.coach.answer.timeYesterday", { time });
}
return t("insights.coach.answer.timeEarlier", { date: day, time });
}
/**
* The answer's cost as one meta string: tokens with the model when both are
* known, tokens alone otherwise, nothing without a count. The count is the
* server's (`done.usage` live, `tokensUsed` on reload), formatted for the
* reader's locale.
*/
export function answerMetaText(
tokens: number | null | undefined,
model: string | null | undefined,
locale: string,
t: Translate,
): string | null {
if (tokens == null) return null;
let count: string;
try {
count = new Intl.NumberFormat(locale).format(tokens);
} catch {
count = String(tokens);
}
return model
? t("insights.coach.tokensUsedWithModel", { count, model })
: t("insights.coach.tokensUsed", { count });
}
function CopyMessageButton({
content,
strip,
}: {
content: string;
strip: boolean;
}) {
const { t } = useTranslations();
const supported = useClipboardSupported();
const [copied, setCopied] = useState(false);
const handle = useCallback(async () => {
// Assistant prose is copied as the bubble shows it, without chart
// tokens; user text is verbatim.
const text = strip ? stripChartTokens(content) : content;
try {
await navigator.clipboard.writeText(text);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch {
toast.error(t("insights.coach.copyMessageError"));
}
}, [content, strip, t]);
// Absent where the Clipboard API is (plain-HTTP self-hosts) rather than
// error-toasting on tap.
if (!supported) return null;
const label = t("insights.coach.copyMessage");
return (
<button
type="button"
data-slot="coach-copy-message"
onClick={handle}
aria-label={label}
title={label}
className={COACH_ICON_BUTTON}
>
{copied ? (
<Check className="text-success size-4" aria-hidden="true" />
) : (
<Copy className="size-4" aria-hidden="true" />
)}
</button>
);
}
/** Re-runs the question that produced this answer. */
function TryAgainButton({ onRegenerate }: { onRegenerate: () => void }) {
const { t } = useTranslations();
const label = t("insights.coach.regenerate");
return (
<button
type="button"
data-slot="coach-try-again"
onClick={onRegenerate}
aria-label={label}
title={label}
className={COACH_ICON_BUTTON}
>
<RotateCcw className="size-4" aria-hidden="true" />
</button>
);
}
/**
* When the message was written, as text. The visible run is short ("14:32");
* the full date and time ride the `title` and the screen-reader text.
*/
function MessageTime({ iso }: { iso: string }) {
const { t } = useTranslations();
const formatters = useFormatters();
// "Today" is fixed when the row mounts; a thread left open past midnight
// keeps its labels until the next render from fresh data.
const [now] = useState(Date.now);
const full = formatters.dateTime(iso);
return (
<time
dateTime={iso}
title={full}
data-slot="coach-message-time"
className="text-muted-foreground shrink-0 px-1 text-xs whitespace-nowrap tabular-nums"
>
<span aria-hidden="true">{messageTimeText(iso, now, formatters, t)}</span>
<span className="sr-only">
{t("insights.coach.messageTimeLabel", { time: full })}
</span>
</time>
);
}
/**
* Stores the user's message in the self-context (Settings → AI) on one tap,
* so it rides every future system prompt. The server picks the field from
* the text itself. The icon confirms in place and a toast says where it
* went; the button never unmounts, so keyboard focus stays on it.
*/
function RememberMessageButton({ content }: { content: string }) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [settled, setSettled] = useState<"adopted" | "duplicate" | null>(null);
const remember = useMutation({
mutationFn: async () =>
apiPost<{ adopted: boolean }>("/api/coach/about-me/adopt", {
answer: content,
}),
onSuccess: (data) => {
if (data.adopted) {
void queryClient.invalidateQueries({
queryKey: queryKeys.coachAboutMe(),
});
}
setSettled(data.adopted ? "adopted" : "duplicate");
// A new entry is a write (success); an existing one is an honest
// non-event (neutral), the same mapping `toastWrittenOutcome` uses.
// Called directly: importing the outcome module here regroups the
// shared chunks and costs ~16 KB gz across the client bundle.
(data.adopted ? toast.success : toast.info)(
t(
data.adopted
? "insights.coach.rememberMessage.done"
: "insights.coach.rememberMessage.duplicate",
),
);
},
onError: () => {
toast.error(t("insights.coach.rememberMessage.failed"));
},
});
const label = settled
? t("insights.coach.answer.rememberDone")
: t("insights.coach.rememberMessage.action");
const inert = settled !== null || remember.isPending;
return (
<button
type="button"
data-slot="coach-remember-message"
data-state={settled ?? (remember.isPending ? "pending" : "idle")}
onClick={() => {
if (!inert) remember.mutate();
}}
// aria-disabled, not disabled: a disabled button drops keyboard focus.
aria-disabled={inert || undefined}
aria-busy={remember.isPending || undefined}
aria-label={label}
title={label}
className={COACH_ICON_BUTTON}
>
{remember.isPending ? (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
) : settled ? (
// Green only for the entry this tap wrote; "already there" stays
// neutral.
<BookmarkCheck
className={cn("size-4", settled === "adopted" && "text-success")}
aria-hidden="true"
/>
) : (
<BookmarkPlus className="size-4" aria-hidden="true" />
)}
</button>
);
}
export interface AssistantMessageActionsProps {
content: string;
/** Read aloud and try again wait until the answer has settled. */
streaming: boolean;
createdAt?: string;
onRegenerate?: () => void;
tokens?: number | null;
model?: string | null;
}
export function AssistantMessageActions({
content,
streaming,
createdAt,
onRegenerate,
tokens,
model,
}: AssistantMessageActionsProps) {
const { t, locale } = useTranslations();
const meta = answerMetaText(tokens, model, resolveIntlLocale(locale), t);
return (
<div
data-slot="coach-answer-actions"
className={cn(
"flex w-full min-w-0 items-center justify-between gap-2 self-stretch",
REVEAL.assistant,
"transition-opacity duration-150 motion-reduce:transition-none",
)}
>
<div
data-slot="coach-answer-actions-left"
className="flex shrink-0 items-center gap-0.5"
>
<CopyMessageButton content={content} strip />
{!streaming && <ReadAloudButton content={content} />}
{!streaming && onRegenerate && (
<TryAgainButton onRegenerate={onRegenerate} />
)}
{createdAt && <MessageTime iso={createdAt} />}
</div>
<p
data-slot="coach-answer-meta"
title={model ?? undefined}
className="text-muted-foreground min-w-0 truncate text-right text-xs tabular-nums"
>
{meta}
</p>
</div>
);
}
export interface UserMessageActionsProps {
content: string;
/** Present once the message is persisted; an optimistic copy has none. */
messageId?: string;
createdAt?: string;
}
export function UserMessageActions({
content,
messageId,
createdAt,
}: UserMessageActionsProps) {
// Only a persisted message that fits the self-context field can be kept.
const canRemember = !!messageId && content.length <= ABOUT_ME_FIELD_MAX_CHARS;
return (
<div
data-slot="coach-user-actions"
className={cn(
"flex items-center justify-end gap-0.5",
REVEAL.user,
"transition-opacity duration-150 motion-reduce:transition-none",
)}
>
<CopyMessageButton content={content} strip={false} />
{canRemember && <RememberMessageButton content={content} />}
{createdAt && <MessageTime iso={createdAt} />}
</div>
);
}
@@ -1,6 +1,13 @@
"use client";
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
import {
Fragment,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Sparkles } from "lucide-react";
import { cn } from "@/lib/utils";
@@ -8,9 +15,10 @@ import { scrollBehaviorForUser } from "@/lib/motion";
import { useTranslations } from "@/lib/i18n/context";
import { PlanProposalCards } from "./plan-proposal-card";
import { ChatBubble } from "./chat-bubble";
import { CoachFollowUpChips } from "./follow-up-chips";
import { ChatBubble, type FollowUpOffer } from "./chat-bubble";
import { CoachMessageDatesProvider } from "./coach-results";
import { ScrollToBottomButton } from "./scroll-to-bottom-button";
import { focusCoachComposer } from "./composer-focus";
import {
EMPTY_LIVE_TURN_KEYS,
messageRenderKey,
@@ -50,6 +58,9 @@ export {
* list grows OR the streaming-content length changes. The user can
* scroll up to read history; we suppress auto-scroll until the next
* "new message" tick if they're not already pinned to the bottom.
* Away from the end, a round button fades in over the thread's foot and
* brings the reader back (`ScrollToBottomButton`); a reply streaming in
* meanwhile never pulls the view down by itself.
*
* Visual identity: user bubbles right-aligned, Dracula purple accent;
* assistant bubbles left-aligned with the gradient sparkle avatar
@@ -268,14 +279,10 @@ export function MessageThread({
const { t } = useTranslations();
const scrollerRef = useRef<HTMLDivElement | null>(null);
const wasPinnedRef = useRef(true);
// v1.4.27 F14 — the evidence disclosure used to honour an opt-in
// `coachPrefs.showEvidenceByDefault` flag that surfaced the raw
// measurement values unconditionally. The flag created an UX trap
// (the maintainer 2026-05-15: "literal metric values exposed under every
// bubble") so the disclosure is now collapsed by default for every
// reply. The user expands by click; the pref is retired in the
// settings sheet so nothing flips it back on.
// The same pinned state as a render value, for the jump-to-latest button.
// The ref stays the source for the auto-scroll (it must not wait for a
// render); the state follows it at most once per frame.
const [pinned, setPinned] = useState(true);
const messages: CoachMessageDTO[] = useMemo(
() => conversation?.messages ?? [],
@@ -334,18 +341,65 @@ export function MessageThread({
optimisticActive && optimisticUser ? optimisticUser.content : null,
);
const chips = latestFollowUps(messages, streaming);
// The chips go to the bubble of the message that offered them, so they
// sit in that answer's column. One object per offer, so the memoised
// bubble sees the same value until the offer itself changes.
const latest = latestFollowUps(messages, streaming);
const chipsMessageId = latest?.messageId ?? null;
const chipsArray = latest?.followUps ?? null;
const chips: FollowUpOffer | null = useMemo(
() =>
chipsMessageId && chipsArray
? { messageId: chipsMessageId, followUps: chipsArray }
: null,
[chipsMessageId, chipsArray],
);
const chipsFor = (messageId: string | null | undefined) =>
onFollowUp && chips && messageId === chips.messageId
? {
followUps: chips,
followUpsDisabled: !!streaming?.inProgress,
onFollowUp,
}
: {};
const hasThread =
messages.length > 0 ||
streamingActive ||
optimisticActive ||
interleavedItems.length > 0;
// Track scroll position so we don't yank the viewport when the user
// is browsing earlier turns.
// is browsing earlier turns. Re-attached when the thread mounts (the
// empty state renders no scroller).
useEffect(() => {
const el = scrollerRef.current;
if (!el) return;
let frame = 0;
const onScroll = () => {
wasPinnedRef.current = isPinnedToBottom(el);
if (frame) return;
frame = window.requestAnimationFrame(() => {
frame = 0;
setPinned(isPinnedToBottom(el));
});
};
el.addEventListener("scroll", onScroll, { passive: true });
return () => el.removeEventListener("scroll", onScroll);
return () => {
el.removeEventListener("scroll", onScroll);
if (frame) window.cancelAnimationFrame(frame);
};
}, [hasThread]);
const scrollToLatest = useCallback(() => {
const el = scrollerRef.current;
if (!el) return;
// Focus first: pinning hides this button (aria-hidden), and focus must
// not sit on a control that has left the accessibility tree.
focusCoachComposer();
wasPinnedRef.current = true;
setPinned(true);
el.scrollTo({ top: el.scrollHeight, behavior: scrollBehaviorForUser() });
}, []);
// Auto-scroll on new messages OR streaming-content growth, but only
@@ -389,12 +443,7 @@ export function MessageThread({
return () => vv.removeEventListener("resize", handleResize);
}, []);
if (
messages.length === 0 &&
!streamingActive &&
!optimisticActive &&
interleavedItems.length === 0
) {
if (!hasThread) {
return (
<div
data-slot="coach-message-thread"
@@ -417,150 +466,152 @@ export function MessageThread({
return (
<CoachMessageDatesProvider dates={messageDates}>
{/* The positioning frame for the jump-to-latest button. It takes the
thread's place in the flex column; the scroller inside stays the
one scroll container. */}
<div
ref={scrollerRef}
data-slot="coach-message-thread"
className={cn(
// v1.18.6.1 — `min-h-0 flex-1` (not `h-full`) so the scroll region
// resolves its height from the flex parent rather than a 100%-of-auto
// chain that let the thread grow instead of scroll.
// v1.18.7 — calmer vertical rhythm (gap-6) and more generous top/
// bottom breathing room so the conversation reads like a document,
// not a chat log packed against the chrome.
"flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto px-4 py-6 sm:px-6",
// v1.18.6 (CCH-01) / v1.18.7 — on the wide page surface an edge-to-
// edge thread sprawled the prose past a readable measure. Centre the
// content on a narrower, calmer Claude/ChatGPT-like column
// (`max-w-2xl`) via an `mx-auto` inner gutter; the scrollbar still
// rides the surface edge. The drawer surface is already narrow, so
// the cap only bites on the wide page surface.
"[&>*]:mx-auto [&>*]:w-full [&>*]:max-w-2xl",
"scroll-smooth",
// v1.18.7 — thin, rounded, subtle scrollbar (WebKit + Firefox),
// component-scoped via Tailwind arbitrary variants so globals.css
// stays untouched. Replaces the default boxy/angular track.
COACH_SCROLLBAR,
)}
data-slot="coach-message-thread-frame"
className="relative flex min-h-0 flex-1 flex-col"
>
{[
...messages.map((m, idx) => {
// v1.16.5 — a guided question renders directly above the user
// message that answered it.
const guidedBefore = placement.before.get(m.id);
// v1.22 — "Try again": resolve the user message that produced this
// assistant reply (the nearest preceding user turn) so the surface
// can resubmit it. Null when there is none → no regenerate action.
let precedingUserContent: string | null = null;
if (m.role === "assistant" && onRegenerate) {
for (let j = idx - 1; j >= 0; j--) {
if (messages[j].role === "user") {
precedingUserContent = messages[j].content;
break;
<div
ref={scrollerRef}
data-slot="coach-message-thread"
className={cn(
// v1.18.6.1 — `min-h-0 flex-1` (not `h-full`) so the scroll region
// resolves its height from the flex parent rather than a 100%-of-auto
// chain that let the thread grow instead of scroll.
// v1.18.7 — calmer vertical rhythm (gap-6) and more generous top/
// bottom breathing room so the conversation reads like a document,
// not a chat log packed against the chrome.
"flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto px-4 py-6 sm:px-6",
// v1.18.6 (CCH-01) / v1.18.7 — on the wide page surface an edge-to-
// edge thread sprawled the prose past a readable measure. Centre the
// content on a narrower, calmer Claude/ChatGPT-like column
// (`max-w-2xl`) via an `mx-auto` inner gutter; the scrollbar still
// rides the surface edge. The drawer surface is already narrow, so
// the cap only bites on the wide page surface.
"[&>*]:mx-auto [&>*]:w-full [&>*]:max-w-2xl",
"scroll-smooth",
// v1.18.7 — thin, rounded, subtle scrollbar (WebKit + Firefox),
// component-scoped via Tailwind arbitrary variants so globals.css
// stays untouched. Replaces the default boxy/angular track.
COACH_SCROLLBAR,
)}
>
{[
...messages.map((m, idx) => {
// v1.16.5 — a guided question renders directly above the user
// message that answered it.
const guidedBefore = placement.before.get(m.id);
// v1.22 — "Try again": resolve the user message that produced this
// assistant reply (the nearest preceding user turn) so the surface
// can resubmit it. Null when there is none → no regenerate action.
let precedingUserContent: string | null = null;
if (m.role === "assistant" && onRegenerate) {
for (let j = idx - 1; j >= 0; j--) {
if (messages[j].role === "user") {
precedingUserContent = messages[j].content;
break;
}
}
}
}
const bubble = (
<ChatBubble
role={m.role}
content={m.content}
metricSource={m.metricSource}
conversationId={conversation?.id ?? null}
providerType={m.providerType}
messageId={m.id}
tokensUsed={m.tokensUsed}
model={m.model}
createdAt={m.createdAt}
// The turn that just streamed keeps its live tables, so the
// swap to the persisted copy does not refetch and remount them.
{...(m.id === streaming?.messageId &&
streaming.results.length > 0
? { results: streaming.results }
: {})}
onRegenerate={
precedingUserContent !== null && onRegenerate
? () => onRegenerate(precedingUserContent as string)
: undefined
}
/>
);
const key = messageRenderKey(liveKeys, m.id);
return (
<Fragment key={key}>
{guidedBefore?.node}
{m.role === "assistant" ? (
<AssistantTurn turnKey={key} live={false}>
{bubble}
</AssistantTurn>
) : (
bubble
)}
</Fragment>
);
}),
// v1.16.5 — guided question whose answer is still optimistic-only.
...placement.beforeOptimistic.map((i) => (
<Fragment key={i.key}>{i.node}</Fragment>
)),
// v1.4.25 W5 — the optimistic user bubble sits between the
// persisted history and the streaming assistant placeholder, so
// the visible order matches the person's mental model. The send
// hook drops it once the persisted twin lands.
optimisticActive && optimisticUser ? (
<ChatBubble
key={optimisticUser.localId}
role="user"
content={optimisticUser.content}
/>
) : null,
streamingActive && streaming ? (
// Same key, fragment and wrapper as its persisted copy above.
<Fragment key={liveKeys.current ?? "coach-turn-live"}>
{null}
<AssistantTurn
turnKey={liveKeys.current ?? "coach-turn-live"}
live
>
const bubble = (
<ChatBubble
role="assistant"
content={streaming.content}
metricSource={streaming.metricSource}
suggestion={streaming.suggestion}
suggestedAction={streaming.suggestedAction}
steps={streaming.steps}
results={streaming.results}
providerType={streaming.inProgress ? "streaming" : null}
inProgress={streaming.inProgress}
errorCode={streaming.errorCode}
// v1.18.9 — live word-fade + the just-landed token footer.
streaming
usage={streaming.usage}
role={m.role}
content={m.content}
metricSource={m.metricSource}
conversationId={conversation?.id ?? null}
providerType={m.providerType}
messageId={m.id}
tokensUsed={m.tokensUsed}
model={m.model}
createdAt={m.createdAt}
// The turn that just streamed keeps its live tables, so the
// swap to the persisted copy does not refetch and remount them.
{...(m.id === streaming?.messageId &&
streaming.results.length > 0
? { results: streaming.results }
: {})}
{...(m.role === "assistant" ? chipsFor(m.id) : {})}
onRegenerate={
precedingUserContent !== null && onRegenerate
? () => onRegenerate(precedingUserContent as string)
: undefined
}
/>
</AssistantTurn>
</Fragment>
) : null,
]}
{/* v1.39.4 — follow-up chips under the latest assistant turn only. */}
{onFollowUp && chips ? (
<CoachFollowUpChips
followUps={chips.followUps}
messageId={chips.messageId}
disabled={!!streaming?.inProgress}
onSelect={onFollowUp}
/>
) : null}
{/* v1.16.5 — thread tail: the current guided question and/or the
);
const key = messageRenderKey(liveKeys, m.id);
return (
<Fragment key={key}>
{guidedBefore?.node}
{m.role === "assistant" ? (
<AssistantTurn turnKey={key} live={false}>
{bubble}
</AssistantTurn>
) : (
bubble
)}
</Fragment>
);
}),
// v1.16.5 — guided question whose answer is still optimistic-only.
...placement.beforeOptimistic.map((i) => (
<Fragment key={i.key}>{i.node}</Fragment>
)),
// v1.4.25 W5 — the optimistic user bubble sits between the
// persisted history and the streaming assistant placeholder, so
// the visible order matches the person's mental model. The send
// hook drops it once the persisted twin lands.
optimisticActive && optimisticUser ? (
<ChatBubble
key={optimisticUser.localId}
role="user"
content={optimisticUser.content}
/>
) : null,
streamingActive && streaming ? (
// Same key, fragment and wrapper as its persisted copy above.
<Fragment key={liveKeys.current ?? "coach-turn-live"}>
{null}
<AssistantTurn
turnKey={liveKeys.current ?? "coach-turn-live"}
live
>
<ChatBubble
role="assistant"
content={streaming.content}
metricSource={streaming.metricSource}
suggestion={streaming.suggestion}
suggestedAction={streaming.suggestedAction}
steps={streaming.steps}
results={streaming.results}
providerType={streaming.inProgress ? "streaming" : null}
inProgress={streaming.inProgress}
errorCode={streaming.errorCode}
// v1.18.9 — live word-fade + the just-landed token footer.
streaming
usage={streaming.usage}
{...chipsFor(streaming.messageId)}
/>
</AssistantTurn>
</Fragment>
) : null,
]}
{/* v1.16.5 — thread tail: the current guided question and/or the
closing summary follow the last completed turn. */}
{placement.tail.map((i) => (
<Fragment key={i.key}>{i.node}</Fragment>
))}
{/* Plan-proposal confirm cards for THIS conversation. The extractor
{placement.tail.map((i) => (
<Fragment key={i.key}>{i.node}</Fragment>
))}
{/* Plan-proposal confirm cards for THIS conversation. The extractor
runs after the turn (memory-refresh worker), so the block appears at
the thread tail once the proposal lands rather than under a specific
bubble; the component itself slow-polls and renders nothing while
the conversation has no open proposal. */}
{conversation ? (
<PlanProposalCards conversationId={conversation.id} />
) : null}
{conversation ? (
<PlanProposalCards conversationId={conversation.id} />
) : null}
</div>
<ScrollToBottomButton visible={!pinned} onClick={scrollToLatest} />
</div>
</CoachMessageDatesProvider>
);
@@ -1,37 +0,0 @@
"use client";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.18.9 — quiet per-message token-usage caption under an assistant
* bubble. Server-authoritative: it renders the count the server reported
* (`done.usage.totalTokens` for the live turn, `CoachMessageDTO.tokensUsed`
* on reload), never a client-side recomputation. No cost — BYOK users pay
* the provider directly and a price table would only drift.
*
* Renders nothing when there is no count (user turns, refusals, in-flight
* bubbles, and pre-feature persisted rows all carry null).
*/
export interface MessageTokenFooterProps {
tokens: number | null | undefined;
/** Provider model name (e.g. `gpt-4o`); appended when present. */
model?: string | null;
}
export function MessageTokenFooter({ tokens, model }: MessageTokenFooterProps) {
const { t } = useTranslations();
if (tokens == null) return null;
const label = model
? t("insights.coach.tokensUsedWithModel", { count: tokens, model })
: t("insights.coach.tokensUsed", { count: tokens });
return (
<p
data-slot="coach-token-footer"
className="text-muted-foreground text-xs tabular-nums"
>
{label}
</p>
);
}
@@ -1,11 +1,10 @@
"use client";
/**
* v1.39.4 — how an answer was worked out, as one muted line inside the
* evidence disclosure: sources, windows, counts and aggregation, rendered
* v1.39.4 — how an answer was worked out, as one muted line at the end of
* the open steps list: sources, windows, counts and aggregation, rendered
* on the server in the request locale. Never a health value, so it sits in
* the meta tier beside the key values it explains rather than repeating
* them.
* the meta tier.
*/
import { useTranslations } from "@/lib/i18n/context";
import { COACH_METHOD_KEYS } from "@/lib/ai/coach/dialog-keys";
@@ -260,9 +260,9 @@ export function ReadAloudButton({ content }: { content: string }) {
className={COACH_ICON_BUTTON}
>
{speaking ? (
<VolumeX className="size-3.5" aria-hidden="true" />
<VolumeX className="size-4" aria-hidden="true" />
) : (
<Volume2 className="size-3.5" aria-hidden="true" />
<Volume2 className="size-4" aria-hidden="true" />
)}
</button>
);
@@ -0,0 +1,54 @@
"use client";
/**
* The round "jump to latest" button over the bottom of the Coach thread.
*
* It fades in once the reader has scrolled away from the end and fades out
* at the end. While hidden it stays mounted (so the fade has something to
* run on) but leaves the accessibility tree and the tab order and takes no
* pointer events. With reduced motion it switches without a transition.
*/
import { ArrowDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
export interface ScrollToBottomButtonProps {
visible: boolean;
onClick: () => void;
}
export function ScrollToBottomButton({
visible,
onClick,
}: ScrollToBottomButtonProps) {
const { t } = useTranslations();
const label = t("insights.coach.answer.scrollToBottom");
return (
<Button
type="button"
variant="outline"
size="icon"
data-slot="coach-scroll-to-bottom"
data-visible={visible ? "true" : "false"}
aria-label={label}
title={label}
aria-hidden={visible ? undefined : true}
tabIndex={visible ? undefined : -1}
onClick={onClick}
className={cn(
"absolute bottom-3 left-1/2 z-10 size-11 -translate-x-1/2 rounded-full shadow-md sm:size-9",
// Opaque in both themes: it floats over prose, which must not show
// through (the outline variant's dark fill is translucent).
"bg-background dark:bg-background dark:hover:bg-accent",
"transition-[opacity,translate] duration-200 motion-reduce:transition-none",
visible
? "translate-y-0 opacity-100"
: "pointer-events-none translate-y-2 opacity-0",
)}
>
<ArrowDown className="size-4" aria-hidden="true" />
</Button>
);
}
@@ -1,110 +0,0 @@
"use client";
import { Link2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import type { CoachProvenance } from "@/lib/ai/coach/types";
/**
* v1.4.20 phase B2b — provenance chip row.
*
* Renders the labels-only `metricSourceJson` envelope that the backend
* attaches to every assistant message. We never render raw values here
* — only metric + window labels + sample counts so that the row
* remains queryable / analytics-safe and keeps PII out of any future
* server-side log capture.
*
* The chip click is a no-op for v1.4.20; a chart deeplink will land in
* B3 once the correlation row knows which window to focus.
*/
export interface SourceChipsProps {
provenance: CoachProvenance | null;
className?: string;
}
// v1.7.0 — every provenance metric token resolves to
// `insights.coach.metric.<token>`. The clustered taxonomy added ~26
// new tokens; the key is derived from the token rather than maintained
// in a hand-listed map. The resolver falls back to the raw token when
// a locale file lags, and `messages/en.json` carries one leaf per
// token (propagated to all locales by the locale-integrity guard).
function metricI18nKey(metric: CoachProvenance["metrics"][number]): string {
return `insights.coach.metric.${metric}`;
}
const WINDOW_KEYS: Record<CoachProvenance["windows"][number], string> = {
last7days: "insights.coach.window.last7days",
last30days: "insights.coach.window.last30days",
last90days: "insights.coach.window.last90days",
// v1.4.27 B7 / BL-P6-4 — year-in-review window. The i18n key
// resolves to a localised label; absent translations fall back
// through the resolver to the English source.
lastYear: "insights.coach.window.lastYear",
allTime: "insights.coach.window.allTime",
};
export function SourceChips({ provenance, className }: SourceChipsProps) {
const { t } = useTranslations();
if (!provenance) return null;
const { metrics, windows, counts } = provenance;
if (metrics.length === 0 && windows.length === 0) return null;
// We pair each metric with its first window (the assistant only ever
// surfaces a single window-per-metric in v1.4.20). When the provenance
// envelope has no windows (e.g. refusal path with `windows: []`), we
// still render the metric as a standalone chip so the user gets the
// "general" tag visibly.
const primaryWindowKey = windows[0] ?? null;
const chips = metrics.map((metric) => {
const metricLabel = t(metricI18nKey(metric));
const windowLabel = primaryWindowKey
? t(WINDOW_KEYS[primaryWindowKey])
: null;
const count =
counts && metric in counts
? counts[metric as keyof typeof counts]
: undefined;
return {
key: `${metric}-${primaryWindowKey ?? "none"}`,
metric,
metricLabel,
windowLabel,
count,
};
});
return (
<div
data-slot="coach-source-chips"
className={cn("flex flex-wrap gap-1.5", className)}
>
{chips.map((chip) => (
<span
key={chip.key}
data-slot="coach-source-chip"
data-metric={chip.metric}
className={cn(
"border-info/25 text-info",
"inline-flex items-center gap-1 rounded-full border bg-transparent",
"px-2 py-0.5 text-xs leading-none",
)}
>
<Link2 className="h-2.5 w-2.5" aria-hidden="true" />
<span className="font-medium">{chip.metricLabel}</span>
{chip.windowLabel && (
<span data-slot="coach-source-window" className="text-info">
· {chip.windowLabel}
</span>
)}
{typeof chip.count === "number" && chip.count > 0 && (
<span data-slot="coach-source-count" className="text-info">
· n={chip.count}
</span>
)}
</span>
))}
</div>
);
}
@@ -4,6 +4,7 @@ import { useState } from "react";
import { Eye, Loader2, Target } from "lucide-react";
import { toast } from "sonner";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
Select,
@@ -16,6 +17,11 @@ import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { useCoachPrefs, useSaveCoachPrefs } from "@/hooks/use-coach-prefs";
import { CLUSTER_SOURCES } from "@/lib/ai/coach/clusters";
import {
COACH_HISTORY_WINDOWS,
HISTORY_REACH_DAYS,
lookbackText,
} from "@/lib/ai/coach/history-reach";
import {
DEFAULT_COACH_CLUSTERS,
coachDataClusterEnum,
@@ -45,12 +51,12 @@ import {
const CLUSTER_OPTIONS: ReadonlyArray<CoachDataCluster> =
coachDataClusterEnum.options;
const WINDOW_OPTIONS: ReadonlyArray<CoachDefaultWindow> = [
"last7days",
"last30days",
"last90days",
"allTime",
];
/**
* The lookback options, narrowest first. The choice is a limit
* the server enforces on every Coach read (`history-reach.ts`), so the text
* under the picker says what is read, with the numbers the readers use.
*/
const WINDOW_OPTIONS = COACH_HISTORY_WINDOWS;
export interface SourcesRailProps {
className?: string;
@@ -67,7 +73,7 @@ export interface SourcesRailProps {
}
export function SourcesRail({ className, activeScopeLabel }: SourcesRailProps) {
const { t } = useTranslations();
const { t, tCount } = useTranslations();
const { data: prefs } = useCoachPrefs();
const save = useSaveCoachPrefs();
@@ -80,6 +86,8 @@ export function SourcesRail({ className, activeScopeLabel }: SourcesRailProps) {
prefs?.dataClusters ?? DEFAULT_COACH_CLUSTERS,
);
const activeWindow: CoachDefaultWindow = prefs?.defaultWindow ?? "allTime";
const activeText = lookbackText(activeWindow);
const bounded = HISTORY_REACH_DAYS[activeWindow] !== null;
const [pending, setPending] = useState<CoachDataCluster | "window" | null>(
null,
);
@@ -151,16 +159,13 @@ export function SourcesRail({ className, activeScopeLabel }: SourcesRailProps) {
</div>
) : null}
{/* Window selector — persists to `coachPrefs.defaultWindow` so the
chosen timeframe sticks across drawer opens and matches what
the cog sheet shows. */}
{/* Lookback limit — persists to `coachPrefs.defaultWindow`. The line
under the picker states what the Coach reads at this setting, with
the numbers the server readers use. */}
<div data-slot="coach-sources-window" className="flex flex-col gap-1">
<label
htmlFor="coach-sources-window-select"
className="text-muted-foreground text-xs font-medium tracking-wide uppercase"
>
{t("insights.coach.windowLabel")}
</label>
<Label htmlFor="coach-sources-window-select" noColon>
{t("insights.coach.lookback.label")}
</Label>
<Select
value={activeWindow}
onValueChange={(v) => setWindow(v as CoachDefaultWindow)}
@@ -170,18 +175,33 @@ export function SourcesRail({ className, activeScopeLabel }: SourcesRailProps) {
id="coach-sources-window-select"
data-slot="coach-sources-window-trigger"
size="default"
className="h-9 text-sm"
className="w-full text-sm"
aria-describedby="coach-sources-window-detail"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{WINDOW_OPTIONS.map((w) => (
<SelectItem key={w} value={w} className="text-sm">
{t(`insights.coach.window.${w}`)}
{t(
`insights.coach.lookback.option.${w}`,
lookbackText(w).optionParams,
)}
</SelectItem>
))}
</SelectContent>
</Select>
<p
id="coach-sources-window-detail"
data-slot="coach-sources-window-detail"
className="text-muted-foreground text-xs leading-snug"
>
{t(
`insights.coach.lookback.${activeText.detailKey}`,
activeText.detailParams,
)}
{bounded ? ` ${t("insights.coach.lookback.note")}` : null}
</p>
</div>
{/* Persisted cluster toggles — the same set the settings cog
@@ -201,11 +221,15 @@ export function SourcesRail({ className, activeScopeLabel }: SourcesRailProps) {
data-slot="coach-sources-row"
data-source={cluster}
data-active={checked ? "true" : "false"}
// A switched-off row is told apart by its frame (dashed, no
// fill) and its switch, never by fading the text: opacity over
// muted text fell below WCAG AA in both themes.
className={cn(
"border-border/60 bg-muted/30 flex items-center gap-2.5",
"rounded-lg border px-3",
"flex items-center gap-2.5 rounded-lg border px-3",
"min-h-11 py-2",
!checked && "opacity-60",
checked
? "border-border/60 bg-muted/30"
: "border-border border-dashed bg-transparent",
)}
>
<div className="flex min-w-0 flex-1 flex-col">
@@ -216,9 +240,7 @@ export function SourcesRail({ className, activeScopeLabel }: SourcesRailProps) {
{t(`insights.coach.cluster.${cluster}.label`)}
</label>
<span className="text-muted-foreground text-xs leading-snug">
{t("insights.coach.sourcesMemberCount", {
count: memberCount,
})}
{tCount("insights.coach.sourcesMemberCount", memberCount)}
</span>
</div>
{pending === cluster ? (
@@ -13,11 +13,17 @@
* server's enums and counts; a row never shows a value, a tool argument or
* model text.
*
* The open list also says how the answer was worked out (the method line)
* and holds the tables the answer read without pointing at them. An older
* message saved before steps existed folds into "Looked at 3 areas" and
* lists the areas from its provenance, without counts; a metric token the
* bundle has no name for is left out rather than shown as a raw key.
*
* Screen readers hear each completed step once, through a polite status
* region, at most one announcement per `ANNOUNCE_THROTTLE_MS` (the latest
* wins). A reloaded conversation announces nothing.
*/
import { useEffect, useId, useRef, useState } from "react";
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
import {
Check,
ChevronRight,
@@ -36,7 +42,13 @@ import {
coachPeriodLabelKey,
coachWindowLabelKey,
} from "@/lib/ai/coach/dialog-keys";
import type { CoachStep } from "@/lib/ai/coach/types";
import type {
CoachMethod,
CoachProvenanceMetric,
CoachStep,
} from "@/lib/ai/coach/types";
import { CoachMethodLine } from "./method-line";
export interface CoachTurnStepsProps {
steps: CoachStep[];
@@ -44,6 +56,21 @@ export interface CoachTurnStepsProps {
active: boolean;
}
export interface CoachTurnStepsDetailProps extends CoachTurnStepsProps {
/**
* The provenance metrics, for a message saved before steps existed. Read
* only when `steps` is empty.
*/
areas?: readonly CoachProvenanceMetric[];
/** How the answer was worked out; shown at the end of the open list. */
method?: CoachMethod | null;
/**
* The tables the answer read without pointing at them, shown last in the
* open list. Pass it only when there is something to show.
*/
dataUsed?: ReactNode;
}
/** The shortest gap between two screen-reader announcements. */
export const ANNOUNCE_THROTTLE_MS = 1500;
@@ -238,6 +265,26 @@ function StepIcon({ step, active }: { step: CoachStep; active: boolean }) {
);
}
/**
* The areas an older message drew on, in the order its provenance lists
* them, by their display names. A token without a name in the bundle is
* dropped, and so is `general`, which names no area of the record.
*/
export function legacyAreaLabels(
areas: readonly CoachProvenanceMetric[] | undefined,
t: Translate,
): string[] {
const out: string[] = [];
for (const area of areas ?? []) {
if (area === "general") continue;
const key = `insights.coach.metric.${area}`;
const label = t(key);
if (label === key || out.includes(label)) continue;
out.push(label);
}
return out;
}
/** The rows under the header: one per step, in the order they started. */
export function CoachTurnStepList({
id,
@@ -250,7 +297,7 @@ export function CoachTurnStepList({
id={id}
data-slot="coach-turn-steps-list"
aria-label={t(COACH_STEP_UI_KEYS.listLabel)}
className="border-border/60 mt-1.5 ml-1.5 flex flex-col gap-1 border-l pl-2.5"
className="flex flex-col gap-1"
>
{steps.map((step) => {
const { title, meta } = describeStep(step, t, tCount);
@@ -279,7 +326,66 @@ export function CoachTurnStepList({
);
}
export function CoachTurnSteps({ steps, active }: CoachTurnStepsProps) {
/**
* The open list: the steps (or an older message's areas), then the method
* line, then the tables the answer read. One left rule holds them together.
*/
export function CoachTurnStepsPanel({
id,
steps,
active,
areaLabels,
method,
dataUsed,
}: CoachTurnStepsProps & {
id?: string;
areaLabels: string[];
method?: CoachMethod | null;
dataUsed?: ReactNode;
}) {
const { t } = useTranslations();
return (
<div
id={id}
data-slot="coach-turn-steps-panel"
className="border-border/60 mt-1.5 ml-1.5 flex min-w-0 flex-col gap-2 border-l pl-2.5"
>
{steps.length > 0 ? (
<CoachTurnStepList steps={steps} active={active} />
) : areaLabels.length > 0 ? (
<ul
data-slot="coach-turn-areas"
aria-label={t(COACH_STEP_UI_KEYS.listLabel)}
className="flex flex-col gap-1"
>
{areaLabels.map((label) => (
<li
key={label}
data-slot="coach-turn-area"
className="text-foreground/80 flex items-start gap-1.5 leading-relaxed"
>
<Check
aria-hidden="true"
className="text-muted-foreground mt-0.5 size-3 shrink-0"
/>
<span className="min-w-0">{label}</span>
</li>
))}
</ul>
) : null}
{!active && <CoachMethodLine method={method ?? null} />}
{!active && dataUsed}
</div>
);
}
export function CoachTurnSteps({
steps,
active,
areas,
method,
dataUsed,
}: CoachTurnStepsDetailProps) {
const { t, tCount } = useTranslations();
const listId = useId();
// Closed by default; the reader's toggle holds until the turn ends, and
@@ -294,14 +400,31 @@ export function CoachTurnSteps({ steps, active }: CoachTurnStepsProps) {
t(COACH_STEP_UI_KEYS.announceDone, { label: rowText(step, t, tCount) }),
);
if (steps.length === 0) return null;
const areaLabels = steps.length === 0 ? legacyAreaLabels(areas, t) : [];
const hasMethod = !active && !!method?.text;
const hasDataUsed = !active && dataUsed != null && dataUsed !== false;
if (
steps.length === 0 &&
areaLabels.length === 0 &&
!hasMethod &&
!hasDataUsed
) {
return null;
}
const open = openOverride ?? false;
const current = active ? currentStep(steps) : null;
const header = current
? null
: steps.length > 0
? tCount("coach.step.headerDone", countSources(steps))
: areaLabels.length > 0
? tCount("insights.coach.answer.areasDone", areaLabels.length)
: t(COACH_STEP_UI_KEYS.listLabel);
return (
<div
data-slot="coach-turn-steps"
className="flex max-w-full flex-col text-xs"
className="flex w-full max-w-full min-w-0 flex-col self-stretch text-xs"
>
<button
type="button"
@@ -345,11 +468,20 @@ export function CoachTurnSteps({ steps, active }: CoachTurnStepsProps) {
</span>
) : (
<span data-slot="coach-turn-steps-done" className="min-w-0 truncate">
{tCount("coach.step.headerDone", countSources(steps))}
{header}
</span>
)}
</button>
{open && <CoachTurnStepList id={listId} steps={steps} active={active} />}
{open && (
<CoachTurnStepsPanel
id={listId}
steps={steps}
active={active}
areaLabels={areaLabels}
method={method}
dataUsed={hasDataUsed ? dataUsed : null}
/>
)}
<span
role="status"
aria-live="polite"
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { TopBarActions, TopBarActionsOutlet } from "../top-bar-actions";
describe("top bar actions slot", () => {
it("renders an empty outlet that takes itself out of the row", () => {
const html = renderToStaticMarkup(
<TopBarActionsOutlet className="ml-auto flex empty:hidden" />,
);
expect(html).toBe(
'<div data-slot="top-bar-actions" class="ml-auto flex empty:hidden"></div>',
);
});
it("renders nothing in place: page actions only ever appear in the outlet", () => {
// On the server (and before the top bar has mounted) there is no outlet,
// so the page's actions render nowhere rather than inline in the page.
const html = renderToStaticMarkup(
<div>
<TopBarActions>
<button type="button">Toggle</button>
</TopBarActions>
</div>,
);
expect(html).toBe("<div></div>");
});
});
+53
View File
@@ -0,0 +1,53 @@
"use client";
import { useSyncExternalStore } from "react";
import { createPortal } from "react-dom";
/**
* A page-owned slot at the trailing edge of the top bar.
*
* The top bar is shell chrome and knows nothing about pages; a page that
* needs a control up there (the Coach's conversations-panel toggle) renders
* `<TopBarActions>` anywhere in its own tree and the content is portalled
* into `<TopBarActionsOutlet>`. Portalling keeps the control inside the
* page's React tree, so it reads the page's state and context directly and
* no node has to be handed across a context on every render.
*
* The slot empties itself when the page unmounts: the portal goes with the
* component that rendered it, so no other route ever shows a stale control.
*
* The outlet's element is held in a module-level store rather than a context
* provider. The shell mounts exactly one top bar, so there is one outlet, and
* a store keeps the shell's tree untouched.
*/
let outlet: HTMLElement | null = null;
const listeners = new Set<() => void>();
function setOutlet(node: HTMLElement | null) {
if (outlet === node) return;
outlet = node;
for (const listener of listeners) listener();
}
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
/** Where the top bar paints page actions. Empty unless a page fills it. */
export function TopBarActionsOutlet({ className }: { className?: string }) {
return (
<div ref={setOutlet} data-slot="top-bar-actions" className={className} />
);
}
/** Render `children` into the top bar's action slot. */
export function TopBarActions({ children }: { children: React.ReactNode }) {
const target = useSyncExternalStore(
subscribe,
() => outlet,
() => null,
);
if (!target) return null;
return createPortal(children, target);
}
+7
View File
@@ -15,6 +15,7 @@ import {
visibleUtilityDestinations,
} from "@/components/layout/nav-model";
import { SHELL_HEADER_BAND } from "@/components/layout/shell-metrics";
import { TopBarActionsOutlet } from "@/components/layout/top-bar-actions";
import { cn } from "@/lib/utils";
import { Logo } from "@/components/ui/logo";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
@@ -106,6 +107,12 @@ export function TopBar() {
{/* Desktop: empty spacer (user controls are in sidebar) */}
<div className="hidden md:block" />
{/* Page-owned actions (the Coach's conversations toggle). `ml-auto`
keeps them at the trailing edge, right before the mobile avatar
menu; `empty:hidden` takes the slot out of the row on every page
that leaves it empty, so the header lays out exactly as before. */}
<TopBarActionsOutlet className="ml-auto flex items-center gap-1 empty:hidden" />
{/* Mobile-only auth section (desktop uses sidebar user section) */}
<div className="flex items-center gap-2 md:hidden">
{isLoading ? (
@@ -54,16 +54,16 @@ beforeEach(() => {
});
describe("Coach links follow the Coach's capability", () => {
it("the sources pointer links to the Coach while it is available", () => {
it("the sources pointer opens the Coach's settings on what it can see", () => {
expect(render(<CoachPrefsSection isAuthenticated />)).toContain(
'href="/coach"',
'href="/coach?settings=data"',
);
});
it("the sources pointer offers no dead link without the Coach", () => {
coachState.available = false;
const html = render(<CoachPrefsSection isAuthenticated />);
expect(html).not.toContain('href="/coach"');
expect(html).not.toContain('href="/coach?settings=data"');
expect(html).not.toContain("coach-prefs-sources-pointer");
});
@@ -0,0 +1,76 @@
/**
* Settings → Coach says what the Coach reads at the saved setting, in one
* sentence, beside the link into the Coach's own settings: how many data
* areas are on, out of how many, and how far back it looks.
*/
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { describe, expect, it, vi } from "vitest";
vi.mock("@/hooks/use-ai-capability", () => ({
useAiCapability: () => ({
available: true,
reason: null,
onDeviceAllowed: false,
}),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/settings/coach",
}));
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import {
DEFAULT_COACH_CLUSTERS,
DEFAULT_COACH_PREFS,
coachDataClusterEnum,
type CoachPrefs,
} from "@/lib/validations/coach-prefs";
import { CoachPrefsSection } from "../coach-prefs-section";
function summary(prefs: CoachPrefs): string {
const client = new QueryClient({
defaultOptions: { queries: { retry: 0, staleTime: Infinity } },
});
client.setQueryData(queryKeys.coachPrefs(), prefs);
const html = renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">
<CoachPrefsSection isAuthenticated />
</I18nProvider>
</QueryClientProvider>,
);
const match = html.match(
/data-slot="coach-prefs-sources-summary"[^>]*>([^<]*)</,
);
expect(match, "summary rendered").not.toBeNull();
return match![1];
}
const TOTAL = coachDataClusterEnum.options.length;
describe("Settings → Coach sources summary", () => {
it("counts the default areas and says the whole history is read", () => {
expect(summary(DEFAULT_COACH_PREFS)).toBe(
`The Coach sees ${DEFAULT_COACH_CLUSTERS.length} of ${TOTAL} data areas and your whole history.`,
);
});
it("names a bounded lookback in the picker's words", () => {
expect(
summary({
...DEFAULT_COACH_PREFS,
dataClusters: ["cardio", "sleep"],
defaultWindow: "last90days",
}),
).toBe(`The Coach sees 2 of ${TOTAL} data areas and looks back 90 days.`);
expect(
summary({
...DEFAULT_COACH_PREFS,
dataClusters: [],
defaultWindow: "lastYear",
}),
).toBe(`The Coach sees 0 of ${TOTAL} data areas and looks back 12 months.`);
});
});
@@ -14,20 +14,29 @@ import type { ReasoningEffort } from "./shared";
export function ReasoningEffortField({
id,
disabled = false,
value,
onChange,
noColon = false,
}: {
id: string;
/** Locks the select while a write is in flight. */
disabled?: boolean;
value: ReasoningEffort | null;
onChange: (next: ReasoningEffort | null) => void;
/** The Coach's quick settings label their fields without a colon. */
noColon?: boolean;
}) {
const { t } = useTranslations();
return (
<div>
<Label htmlFor={id}>{t("settings.ai.reasoning.label")}</Label>
<Label htmlFor={id} noColon={noColon}>
{t("settings.ai.reasoning.label")}
</Label>
<NativeSelect
id={id}
value={value ?? ""}
disabled={disabled}
onChange={(e) =>
onChange(
e.target.value === "" ? null : (e.target.value as ReasoningEffort),
+56 -16
View File
@@ -41,8 +41,11 @@ import { useTranslations } from "@/lib/i18n/context";
import { useCoachPrefs, useSaveCoachPrefs } from "@/hooks/use-coach-prefs";
import { useAiCapability } from "@/hooks/use-ai-capability";
import { COACH_FOLLOW_UP_UI_KEYS } from "@/lib/ai/coach/dialog-keys";
import { HISTORY_REACH_DAYS, lookbackText } from "@/lib/ai/coach/history-reach";
import {
DEFAULT_COACH_CLUSTERS,
DEFAULT_COACH_PREFS,
coachDataClusterEnum,
type CoachExcludeMetric,
type CoachPrefs,
type CoachTone,
@@ -70,6 +73,32 @@ const CONTEXT_OPTIONS: CoachExcludeMetric[] = [
"anthropometrics",
];
/**
* One sentence on what the Coach reads at the saved setting: how many of the
* data areas are on and how far back it looks, in the words the lookback
* picker uses.
*/
function sourcesSummary(
prefs: CoachPrefs,
t: ReturnType<typeof useTranslations>["t"],
): string {
const params = {
count: (prefs.dataClusters ?? DEFAULT_COACH_CLUSTERS).length,
total: coachDataClusterEnum.options.length,
};
const window = prefs.defaultWindow;
if (HISTORY_REACH_DAYS[window] === null) {
return t("insights.coach.settingsSourcesSummaryAll", params);
}
return t("insights.coach.settingsSourcesSummary", {
...params,
window: t(
`insights.coach.lookback.option.${window}`,
lookbackText(window).optionParams,
),
});
}
export interface CoachPrefsSectionProps {
isAuthenticated: boolean;
}
@@ -223,26 +252,37 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
</div>
</div>
{/* Data clusters + analysis window live on ONE owner: the
sources rail inside the chat ("What I draw on"). This card
used to render its own copies of both controls; two writable
surfaces for the same persisted fields is exactly the
redundancy the rail was built to end. A pointer replaces
them. Only while the Coach can open: without it the link
would bounce back to Insights and the rail is out of reach. */}
{/* Data clusters + analysis window live on ONE owner: "What I
can see" behind the Coach's settings gear, which
`/coach?settings=data` opens directly. This card used to render
its own copies of both controls; two writable surfaces for the
same persisted fields is exactly the redundancy the rail was
built to end. It says what the Coach sees at the saved setting
and links there. Only while the Coach can open: without it the
link would bounce back to Insights and the rail is out of
reach. */}
{coach.available ? (
<p
<div
data-slot="coach-prefs-sources-pointer"
className="text-muted-foreground border-border/60 rounded-md border border-dashed px-3 py-2 text-xs leading-relaxed"
className="flex items-start justify-between gap-3"
>
{t("insights.coach.settingsSourcesPointer")}{" "}
<Link
href="/coach"
className="text-foreground underline underline-offset-2"
<p
data-slot="coach-prefs-sources-summary"
className="min-w-0 flex-1 text-sm"
>
{t("insights.coach.settingsSourcesPointerLink")}
</Link>
</p>
{sourcesSummary(draft, t)}
</p>
<Button
asChild
variant="outline"
size="sm"
className="min-h-11 shrink-0 sm:min-h-9"
>
<Link href="/coach?settings=data">
{t("insights.coach.settingsSourcesPointerLink")}
</Link>
</Button>
</div>
) : null}
{/* Exclude metrics — checked = excluded. */}
@@ -34,6 +34,12 @@ import { DateField } from "@/components/ui/date-field";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import {
COACH_HISTORY_WINDOWS,
HISTORY_REACH_DAYS,
lookbackText,
} from "@/lib/ai/coach/history-reach";
import type { CoachDefaultWindow } from "@/lib/validations/coach-prefs";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { Switch } from "@/components/ui/switch";
import { SettingsCardActions } from "./_card-actions";
@@ -776,6 +782,12 @@ function CoachSettingsForm({
const preferences = asRecord(settings.preferences);
const excluded = new Set(asStringArray(preferences.excludeMetrics));
const dataClusters = new Set(asStringArray(preferences.dataClusters));
const savedWindow = asString(preferences.defaultWindow, "allTime");
const [lookback, setLookback] = useState<CoachDefaultWindow>(
(COACH_HISTORY_WINDOWS as readonly string[]).includes(savedWindow)
? (savedWindow as CoachDefaultWindow)
: "allTime",
);
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
@@ -847,28 +859,44 @@ function CoachSettingsForm({
</option>
</NativeSelect>
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-coach-window">
{t("settings.sharedRecord.managedSettings.coach.defaultWindow")}
{/* The same lookback options and wording as the Coach's own picker:
the choice is a limit on every Coach read. */}
<div className="grid gap-1 text-sm">
<label htmlFor="managed-coach-window">
{t("insights.coach.lookback.label")}
</label>
<NativeSelect
defaultValue={asString(preferences.defaultWindow, "allTime")}
value={lookback}
onChange={(event) =>
setLookback(event.currentTarget.value as CoachDefaultWindow)
}
disabled={disabled}
id="managed-coach-window"
name="defaultWindow"
aria-describedby="managed-coach-window-detail"
>
<option value="last7days">
{t("settings.sharedRecord.managedSettings.coach.last7days")}
</option>
<option value="last30days">
{t("settings.sharedRecord.managedSettings.coach.last30days")}
</option>
<option value="last90days">
{t("settings.sharedRecord.managedSettings.coach.last90days")}
</option>
<option value="allTime">
{t("settings.sharedRecord.managedSettings.coach.allTime")}
</option>
{COACH_HISTORY_WINDOWS.map((window) => (
<option key={window} value={window}>
{t(
`insights.coach.lookback.option.${window}`,
lookbackText(window).optionParams,
)}
</option>
))}
</NativeSelect>
</label>
<p
id="managed-coach-window-detail"
className="text-muted-foreground text-xs"
>
{t(
`insights.coach.lookback.${lookbackText(lookback).detailKey}`,
lookbackText(lookback).detailParams,
)}
{HISTORY_REACH_DAYS[lookback] !== null
? ` ${t("insights.coach.lookback.note")}`
: null}
</p>
</div>
</div>
<fieldset className="space-y-2">
<legend className="text-sm font-medium">
+10
View File
@@ -88,6 +88,13 @@ export interface ResponsiveSheetProps {
* default, so the width now resolves from this single source.
*/
contentWidth?: "md" | "lg" | "2xl" | "3xl" | "4xl" | "6xl";
/**
* Where focus goes when the surface closes. Radix returns it to a
* `Dialog.Trigger`, and this primitive has none, so a caller whose opener
* should get focus back (a gear, a menu button) hands it over here: call
* `event.preventDefault()` and focus the opener.
*/
onCloseAutoFocus?: (event: Event) => void;
children: React.ReactNode;
}
@@ -136,6 +143,7 @@ export function ResponsiveSheet({
bodyClassName,
showCloseButton = true,
contentWidth = "md",
onCloseAutoFocus,
children,
}: ResponsiveSheetProps) {
const isMobile = useIsMobile();
@@ -144,6 +152,7 @@ export function ResponsiveSheet({
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
onCloseAutoFocus={onCloseAutoFocus}
side="bottom"
showCloseButton={showCloseButton}
data-slot="responsive-sheet-content"
@@ -246,6 +255,7 @@ export function ResponsiveSheet({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
onCloseAutoFocus={onCloseAutoFocus}
showCloseButton={showCloseButton}
data-slot="responsive-sheet-content"
data-variant="dialog"
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import {
COACH_PANEL_STORAGE_KEY,
readCoachPanelPreference,
resolveCoachPanelOpen,
writeCoachPanelPreference,
} from "../use-coach-panel-open";
function memoryStorage(initial: Record<string, string> = {}) {
const map = new Map(Object.entries(initial));
return {
getItem: (key: string) => map.get(key) ?? null,
setItem: (key: string, value: string) => void map.set(key, value),
map,
};
}
const throwing = {
getItem: () => {
throw new Error("SecurityError");
},
setItem: () => {
throw new Error("QuotaExceededError");
},
};
describe("coach panel open preference", () => {
it("opens the docked panel on a first visit", () => {
const storage = memoryStorage();
expect(readCoachPanelPreference(storage)).toBeNull();
expect(resolveCoachPanelOpen(readCoachPanelPreference(storage))).toBe(true);
});
it("remembers a closed panel, and an opened one, across reads", () => {
const storage = memoryStorage();
expect(writeCoachPanelPreference(storage, false)).toBe(true);
expect(storage.map.get(COACH_PANEL_STORAGE_KEY)).toBe("false");
expect(resolveCoachPanelOpen(readCoachPanelPreference(storage))).toBe(
false,
);
writeCoachPanelPreference(storage, true);
expect(resolveCoachPanelOpen(readCoachPanelPreference(storage))).toBe(true);
});
it("treats an unknown stored value as no choice", () => {
const storage = memoryStorage({ [COACH_PANEL_STORAGE_KEY]: "maybe" });
expect(readCoachPanelPreference(storage)).toBeNull();
});
it("falls back to the default when storage throws or is absent", () => {
expect(readCoachPanelPreference(throwing)).toBeNull();
expect(writeCoachPanelPreference(throwing, false)).toBe(false);
expect(readCoachPanelPreference(null)).toBeNull();
expect(writeCoachPanelPreference(undefined, true)).toBe(false);
});
});
+135
View File
@@ -0,0 +1,135 @@
"use client";
import { useCallback, useSyncExternalStore } from "react";
/**
* The Coach page's conversations panel: whether it sits docked beside the
* thread, and whether the person last left it open there.
*
* Docked means a viewport of at least 1280 px (`xl`). Below that the panel is
* a sheet over the page and opens only when asked, so nothing here applies.
* At `xl` it is open on a first visit and remembers the last choice per
* device after that.
*
* The choice is a convenience, not a record: it lives in `localStorage`, every
* read and write is wrapped because storage can throw (private windows,
* blocked site data), and an unreadable store falls back to the first-visit
* default. Both values come through `useSyncExternalStore`, so the server and
* the hydrating client agree (docked = false, open = true) and the live value
* lands on the first client render after hydration.
*/
export const COACH_PANEL_STORAGE_KEY = "healthlog.coach.panelOpen";
/** The breakpoint at which the panel docks beside the thread. */
export const COACH_PANEL_DOCKED_QUERY = "(min-width: 1280px)";
/** Same-tab change signal; the `storage` event only fires in other tabs. */
const CHANGE_EVENT = "healthlog:coach-panel-open";
/**
* The last choice made in this tab. Read when the store has nothing, so a
* click still sticks for the session where storage refuses every write.
*/
let sessionChoice: boolean | null = null;
/**
* The remembered choice. `null` = nothing stored yet (a first visit), or a
* store that cannot be read; the caller treats both as "open".
*/
export function readCoachPanelPreference(
storage: Pick<Storage, "getItem"> | null | undefined,
): boolean | null {
if (!storage) return null;
try {
const raw = storage.getItem(COACH_PANEL_STORAGE_KEY);
if (raw === "true") return true;
if (raw === "false") return false;
return null;
} catch {
return null;
}
}
/** Persist the choice. Returns false when the store refused the write. */
export function writeCoachPanelPreference(
storage: Pick<Storage, "setItem"> | null | undefined,
open: boolean,
): boolean {
if (!storage) return false;
try {
storage.setItem(COACH_PANEL_STORAGE_KEY, String(open));
return true;
} catch {
return false;
}
}
/** The open state the docked panel paints: a first visit opens it. */
export function resolveCoachPanelOpen(stored: boolean | null): boolean {
return stored ?? true;
}
function safeLocalStorage(): Storage | null {
if (typeof window === "undefined") return null;
try {
return window.localStorage;
} catch {
return null;
}
}
function subscribePreference(callback: () => void): () => void {
if (typeof window === "undefined") return () => {};
const onStorage = (event: StorageEvent) => {
if (event.key === null || event.key === COACH_PANEL_STORAGE_KEY) {
callback();
}
};
window.addEventListener("storage", onStorage);
window.addEventListener(CHANGE_EVENT, callback);
return () => {
window.removeEventListener("storage", onStorage);
window.removeEventListener(CHANGE_EVENT, callback);
};
}
function subscribeDocked(callback: () => void): () => void {
if (typeof window === "undefined" || !window.matchMedia) return () => {};
const mql = window.matchMedia(COACH_PANEL_DOCKED_QUERY);
mql.addEventListener("change", callback);
return () => mql.removeEventListener("change", callback);
}
function readDocked(): boolean {
if (typeof window === "undefined" || !window.matchMedia) return false;
return window.matchMedia(COACH_PANEL_DOCKED_QUERY).matches;
}
/**
* `docked`: the viewport is wide enough for the panel to sit beside the
* thread. `open`: the remembered docked state (meaningful only when docked).
* `setOpen` persists and notifies every subscriber in this tab.
*/
export function useCoachPanelOpen(): {
docked: boolean;
open: boolean;
setOpen: (open: boolean) => void;
} {
const docked = useSyncExternalStore(subscribeDocked, readDocked, () => false);
const open = useSyncExternalStore(
subscribePreference,
() =>
resolveCoachPanelOpen(
readCoachPanelPreference(safeLocalStorage()) ?? sessionChoice,
),
() => true,
);
const setOpen = useCallback((next: boolean) => {
sessionChoice = next;
writeCoachPanelPreference(safeLocalStorage(), next);
if (typeof window !== "undefined") {
window.dispatchEvent(new Event(CHANGE_EVENT));
}
}, []);
return { docked, open, setOpen };
}
@@ -90,6 +90,7 @@ describe("probeProviderChain", () => {
entries: [],
localOcrEnabled: false,
managedBy: null,
availableTypes: [],
});
expect(prisma.user.findUnique).not.toHaveBeenCalled();
});
@@ -123,6 +124,22 @@ describe("probeProviderChain", () => {
expect(presence.managedBy).toBe("user");
});
it("names every type whose credential is present, enabled in the chain or not", async () => {
load(
row({
aiProviderChain: [
{ providerType: "codex", enabled: true, priority: 0 },
{ providerType: "openai", enabled: false, priority: 1 },
{ providerType: "anthropic", enabled: true, priority: 2 },
],
aiOpenaiKeyEncrypted: "enc",
}),
);
const presence = await probeProviderChain("u1", OWNER);
// Codex is not signed in and no Anthropic key is saved: neither can answer.
expect(presence.availableTypes).toEqual(["openai"]);
});
it("marks which entries can read an image", async () => {
load(
row({
@@ -162,6 +179,7 @@ describe("probeProviderChain", () => {
entries: [{ providerType: "admin-openai", vision: true }],
localOcrEnabled: false,
managedBy: "server",
availableTypes: ["admin-openai"],
});
});
+6
View File
@@ -60,6 +60,12 @@ export interface ProviderPresence {
/** The person's in-browser OCR opt-in, which lets a text-only provider read a document. */
localOcrEnabled: boolean;
managedBy: AiProviderManagedBy | null;
/**
* Every chain tag whose credential is present, whether or not the person
* enabled it in their chain: what a chain editor may offer as able to
* answer. Absent reads as none, so a hand-built presence offers nothing.
*/
availableTypes?: readonly string[];
/**
* The record's `aiResponseTimeoutSeconds`, read off the same row. Not a
* presence fact, but the account payload publishes the effective timeout
@@ -0,0 +1,489 @@
/**
* The Coach lookback limit holds for every tool.
*
* `coachPrefs.defaultWindow` is a limit: no Coach read may reach
* further back than it, whatever window or period the model asks for. This
* guard runs EVERY tool the model is offered (`COACH_TOOL_DEFS`, `show_result`
* included) through the real executor with a 30-day limit and the widest
* arguments the schema allows (`allTime`, `yearAgo`), once with every snapshot
* section present and once with none (the empty-read path), and records each
* call the executor makes into a reader. A call passes when it carries the
* limit and asks for nothing older than 30 days. A tool passes when it made at
* least one read and every read passed, or when it refused with
* `outside_reach` before reading anything.
*
* A tool added to `COACH_TOOL_DEFS` without a case here fails the first test;
* a case whose tool made no read and did not refuse fails too, so the guard
* cannot go green by having nothing to check.
*
* The second block reads the source: every dispatch branch hands the limit
* on, every Coach `buildCoachSnapshot(` call passes one, and the MCP call
* sites pass none (MCP keeps its own windows). The matchers tolerate any
* whitespace and line breaks, and each asserts a non-zero match count.
*/
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_UNIT_PREFERENCES } from "@/lib/measurements/display-transform";
import type { CoachSnapshotResult } from "@/lib/ai/coach/snapshot";
import type {
CoachResultPeriod,
CoachScope,
CoachScopeWindow,
} from "@/lib/ai/coach/types";
import type { CoachHistoryReach } from "@/lib/ai/coach/history-reach";
const NOW = new Date("2026-10-04T12:00:00Z");
const LIMIT_DAYS = 30;
const FLOOR = NOW.getTime() - LIMIT_DAYS * 86_400_000;
const RANK: Record<CoachScopeWindow, number> = {
last7days: 0,
last30days: 1,
last90days: 2,
lastYear: 3,
allTime: 4,
};
interface Read {
reader: string;
ok: boolean;
detail: string;
}
const reads: Read[] = [];
function windowOk(window: CoachScopeWindow | undefined): boolean {
return window === undefined || RANK[window] <= RANK.last30days;
}
function reachOk(reach: CoachHistoryReach | undefined): boolean {
return reach?.days === LIMIT_DAYS;
}
function record(reader: string, ok: boolean, detail: unknown): void {
reads.push({ reader, ok, detail: JSON.stringify(detail) });
}
let sections: Record<string, unknown> = {};
vi.mock("@/lib/ai/coach/snapshot", () => ({
buildCoachSnapshot: async (
_userId: string,
scope?: CoachScope,
options?: { reach?: CoachHistoryReach },
): Promise<CoachSnapshotResult> => {
record(
"buildCoachSnapshot",
reachOk(options?.reach) && windowOk(scope?.window),
{ window: scope?.window, reach: options?.reach },
);
return {
snapshotJson: JSON.stringify(sections),
sections,
provenance: { windows: [], metrics: [] },
referenceGrounding: null,
units: DEFAULT_UNIT_PREFERENCES,
};
},
}));
vi.mock("@/lib/ai/coach/results/metric-table-tool", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@/lib/ai/coach/results/metric-table-tool")
>();
return {
...actual,
readMetricTable: async (args: {
window: CoachScopeWindow;
period: CoachResultPeriod;
now?: Date;
}) => {
const range = actual.resolveTableRange({
window: args.window,
period: args.period,
timeZone: "UTC",
now: args.now ?? NOW,
});
record("readMetricTable", range.from.getTime() >= FLOOR - 86_400_000, {
window: args.window,
period: args.period,
from: range.from.toISOString(),
});
return null;
},
};
});
vi.mock("@/lib/ai/coach/tools/correlations-read", () => ({
readCoachCorrelations: async (
_userId: string,
_locale: string,
options?: { reach?: CoachHistoryReach },
) => {
record("readCoachCorrelations", reachOk(options?.reach), options);
return { present: false, reason: "no_significant_pattern" };
},
}));
vi.mock("@/lib/ai/coach/illness-snapshot", () => ({
buildIllnessScores: async (
_userId: string,
_now?: Date,
reach?: CoachHistoryReach,
) => {
record("buildIllnessScores", reachOk(reach), reach);
return null;
},
}));
vi.mock("@/lib/ai/coach/tools/availability", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/ai/coach/tools/availability")>();
return {
...actual,
resolveEmptyRead: async (args: {
searchedWindow?: CoachScopeWindow;
reach?: CoachHistoryReach;
}) => {
record(
"resolveEmptyRead",
reachOk(args.reach) && windowOk(args.searchedWindow),
{ window: args.searchedWindow, reach: args.reach },
);
return { present: false, reason: "no_data" };
},
illnessBeyondReach: async (_userId: string, reach: CoachHistoryReach) => {
record("illnessBeyondReach", reachOk(reach), reach);
return false;
},
cycleRecorded: async () => true,
};
});
vi.mock("@/lib/cycle/gate", () => ({
isCycleAvailableForUser: async () => true,
}));
vi.mock("@/lib/i18n/user-locale", () => ({
resolveLocaleForUser: async () => "en",
}));
vi.mock("@/lib/tz/resolver", () => ({
resolveUserTimezone: async () => "UTC",
}));
vi.mock("@/lib/modules/gate", () => ({
resolveModuleMap: async () => ({}),
}));
vi.mock("@/lib/ai/coach/persistence", () => ({
readMessageResults: async () => null,
}));
vi.mock("@/lib/db", () => ({ prisma: {} }));
import {
admittedPriorResults,
executeCoachTool,
} from "@/lib/ai/coach/tools/executor";
import { renderPriorResultRefs } from "@/lib/ai/coach/chat-request-builder";
import {
COACH_TOOL_DEFS,
COACH_TOOL_NAMES,
SHOW_RESULT_TOOL_NAME,
} from "@/lib/ai/coach/tools/definitions";
import { createResultRefAllocator } from "@/lib/ai/coach/results/refs";
import type { PriorResultTurn } from "@/lib/ai/coach/results/refs";
const REACH: CoachHistoryReach = { window: "last30days", days: LIMIT_DAYS };
function storedTable(ref: string, window: CoachScopeWindow) {
return {
ref,
source: {
tool: "get_metric_table" as const,
domain: "bp" as const,
window,
period: "current" as const,
},
shape: "timeSeries" as const,
titleKey: "coach.result.title.byDay",
title: "Blood pressure by day",
rowCount: 3,
chartKind: null,
displayed: true,
};
}
const PRIOR: PriorResultTurn[] = [
{
messageId: "m-earlier",
turnIndex: 1,
results: [storedTable("r1", "allTime"), storedTable("r2", "last7days")],
},
];
/**
* One or more argument sets per tool, each the widest the schema allows.
* Every name in `COACH_TOOL_DEFS` must have an entry.
*/
const CASES: Record<string, Array<Record<string, unknown>>> = {
get_metric_series: [
{ metric: "bp", window: "allTime" },
{ metric: "hrv", window: "lastYear" },
],
get_glucose_panel: [{ window: "allTime" }],
get_sleep: [{ window: "allTime" }],
get_medication_compliance: [{ window: "allTime" }],
get_labs: [{}],
get_illness_recovery: [{}],
get_workouts: [{ window: "allTime" }],
get_cycle: [{}],
get_correlations: [{}],
get_metric_table: [
{ metric: "bp", window: "allTime" },
{ metric: "weight", window: "last30days", period: "yearAgo" },
{ metric: "weight", window: "last7days", period: "previous" },
{ metric: "mood", window: "lastYear", granularity: "day" },
],
[SHOW_RESULT_TOOL_NAME]: [{ ref: "m1.r1" }, { ref: "m1.r2" }],
};
const FULL_SECTIONS: Record<string, unknown> = {
bloodPressure: { aggregate: { avgSys30: 128 } },
heartRateVariability: { aggregate: { avg30: 41 } },
glucose: { unit: "mg/dL", byContext: {} },
sleep: { recent: [] },
compliance: { rate: 0.9 },
workouts: { recent: [] },
labs: { recent: [{ name: "LDL", analyte: "ldl" }] },
illness: { restMode: false },
derived: { READINESS: { value: 60 } },
cycle: { phase: "luteal" },
scope: { sources: ["bp", "weight", "mood", "hrv", "sleep"] },
};
async function runCase(
name: string,
args: Record<string, unknown>,
): Promise<{ reason?: string; reads: Read[] }> {
reads.length = 0;
const result = await executeCoachTool({
userId: "u1",
name,
rawArguments: JSON.stringify(args),
fallbackWindow: "allTime",
reach: REACH,
turn: {
conversationId: "c1",
locale: "en",
priorResults: PRIOR,
refs: createResultRefAllocator(),
now: NOW,
},
});
return { reason: result.reason, reads: [...reads] };
}
describe("every Coach tool honours the lookback limit", () => {
beforeEach(() => {
vi.useFakeTimers({ now: NOW, toFake: ["Date"] });
});
it("has a case for every tool the model is offered", () => {
const offered = COACH_TOOL_DEFS.map((def) => def.name).sort();
expect(offered.length).toBeGreaterThan(0);
expect(offered).toEqual(
[...COACH_TOOL_NAMES, SHOW_RESULT_TOOL_NAME].sort(),
);
expect(Object.keys(CASES).sort()).toEqual(offered);
});
for (const [label, present] of [
["with every section present", FULL_SECTIONS],
["with nothing in the window (the empty-read path)", {}],
] as const) {
it(`reads nothing older than the limit, ${label}`, async () => {
sections = present;
let checkedReads = 0;
let refusals = 0;
for (const [name, argSets] of Object.entries(CASES)) {
for (const args of argSets) {
const outcome = await runCase(name, args);
const where = `${name} ${JSON.stringify(args)}`;
const bad = outcome.reads.filter((read) => !read.ok);
expect(bad, `${where} read past the limit`).toEqual([]);
if (outcome.reads.length === 0) {
// Nothing read: only an explicit refusal is an answer.
expect(outcome.reason, `${where} made no read`).toBe(
"outside_reach",
);
refusals += 1;
}
checkedReads += outcome.reads.length;
}
}
expect(checkedReads).toBeGreaterThan(0);
// The refusals are the cycle tool (it predicts from every cycle), the
// stored all-time table, and the year-ago and previous-period tables
// that do not fit into 30 days.
expect(refusals).toBeGreaterThan(0);
});
}
});
describe("earlier tables answer to the lookback limit", () => {
beforeEach(() => {
vi.useFakeTimers({ now: NOW, toFake: ["Date"] });
});
it("names no earlier table whose range lies beyond the limit", async () => {
// The context line carries the table's window and row count; an
// all-time table stored before the limit was set must not be listed.
const admitted = await admittedPriorResults({
userId: "u1",
prefs: { excludeMetrics: [] },
scope: undefined,
reach: REACH,
prior: PRIOR,
});
const refs = admitted.flatMap((turn) => turn.results.map((r) => r.ref));
expect(refs).toEqual(["r2"]);
const context = renderPriorResultRefs(admitted);
expect(context).toContain("last7days");
expect(context).not.toContain("allTime");
});
it("drops an earlier year-ago table that does not fit", async () => {
const yearAgo = {
...storedTable("r3", "last7days"),
source: { ...storedTable("r3", "last7days").source, period: "yearAgo" },
} as PriorResultTurn["results"][number];
const admitted = await admittedPriorResults({
userId: "u1",
prefs: { excludeMetrics: [] },
scope: undefined,
reach: REACH,
prior: [{ messageId: "m-y", turnIndex: 3, results: [yearAgo] }],
});
expect(admitted).toEqual([]);
});
});
// ── The source ─────────────────────────────────────────────────────────
const ROOT = join(__dirname, "../../../../..");
const COACH_DIR = join(ROOT, "src/lib/ai/coach");
const MCP_DIR = join(ROOT, "src/lib/mcp");
const SOURCE_SNAPSHOT_FILE = "source-snapshot.ts";
function walk(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir)) {
if (entry === "__tests__" || entry === "eval") continue;
const full = join(dir, entry);
if (statSync(full).isDirectory()) out.push(...walk(full));
else if (full.endsWith(".ts")) out.push(full);
}
return out;
}
/** `source` without its comments, so a doc mention is not read as a call. */
function code(source: string): string {
return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
}
/** The argument text of every call to `fn(` in `source`, parentheses balanced. */
function callArguments(raw: string, fn: string): string[] {
const source = code(raw);
const out: string[] = [];
const pattern = new RegExp(`\\b${fn}\\s*\\(`, "g");
let match: RegExpExecArray | null;
while ((match = pattern.exec(source)) !== null) {
// Skip the declaration itself.
const before = source.slice(Math.max(0, match.index - 20), match.index);
if (/function\s+$/.test(before)) continue;
let depth = 1;
let i = match.index + match[0].length;
const start = i;
while (i < source.length && depth > 0) {
if (source[i] === "(") depth += 1;
else if (source[i] === ")") depth -= 1;
i += 1;
}
out.push(source.slice(start, i - 1));
}
return out;
}
describe("the source hands the limit on", () => {
it("passes the limit in every dispatch branch of the executor", () => {
const source = readFileSync(join(COACH_DIR, "tools/executor.ts"), "utf8");
const dispatch = source.slice(
source.indexOf("async function dispatchRead("),
);
const branches = [
...dispatch.matchAll(
/case\s+"(get_[a-z_]+)"\s*:\s*return\s+[A-Za-z]+\s*\(([\s\S]*?)\)\s*;/g,
),
];
expect(branches.length).toBe(COACH_TOOL_NAMES.length);
for (const [, name, args] of branches) {
expect(args, `${name} does not pass reach`).toMatch(/\breach\b/);
}
});
it("passes the limit to every Coach snapshot build", () => {
// `source-snapshot.ts` is the single-metric build only MCP asks for
// (`sourceSnapshot: true`); MCP reads without the Coach's limit, so that
// file is checked the other way round below.
const calls = walk(COACH_DIR)
.filter((file) => !file.endsWith(SOURCE_SNAPSHOT_FILE))
.flatMap((file) => {
const source = readFileSync(file, "utf8");
return ["buildCoachSnapshot", "resolveSnapshotPrelude"].flatMap((fn) =>
callArguments(source, fn).map((args) => ({
file: `${file.slice(ROOT.length + 1)} ${fn}`,
args,
})),
);
});
expect(
calls.filter((c) => c.file.endsWith("resolveSnapshotPrelude")).length,
).toBeGreaterThan(0);
expect(calls.length).toBeGreaterThan(0);
for (const call of calls) {
expect(call.args, `${call.file} builds without a limit`).toMatch(
/\breach\b/,
);
}
});
it("keeps the MCP single-metric build unlimited", () => {
const source = readFileSync(join(COACH_DIR, SOURCE_SNAPSHOT_FILE), "utf8");
const calls = ["buildCoachSnapshot", "resolveSnapshotPrelude"].flatMap(
(fn) => callArguments(source, fn),
);
expect(calls.length).toBeGreaterThan(0);
for (const args of calls) expect(args).not.toMatch(/\breach\b/);
});
it("passes the limit when it lists earlier tables for the model", () => {
const calls = walk(COACH_DIR).flatMap((file) =>
callArguments(readFileSync(file, "utf8"), "admittedPriorResults"),
);
expect(calls.length).toBeGreaterThan(0);
for (const args of calls) expect(args).toMatch(/\breach\b/);
});
it("passes the limit from the chat loop and nowhere from MCP", () => {
const loop = callArguments(
readFileSync(join(COACH_DIR, "tools/loop.ts"), "utf8"),
"executeCoachTool",
);
expect(loop.length).toBeGreaterThan(0);
for (const args of loop) expect(args).toMatch(/\breach\b/);
const mcp = walk(MCP_DIR).flatMap((file) =>
callArguments(readFileSync(file, "utf8"), "executeCoachTool"),
);
expect(mcp.length).toBeGreaterThan(0);
for (const args of mcp) expect(args).not.toMatch(/\breach\b/);
});
});
@@ -0,0 +1,229 @@
/**
* The Coach lookback limit, piece by piece: the window arithmetic, the
* numbers the settings text states, the probe's `outside_reach` (which names
* no figure), the rule the model is given for it, and the DATA INVENTORY row.
* The executor-wide guard is `coach-history-reach-guard.test.ts`; the real
* readers against Postgres are `tests/integration/coach-history-reach.test.ts`.
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
const groupBy = vi.fn();
const findFirst = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
measurement: {
groupBy: (args: unknown) => groupBy(args),
findFirst: (args: unknown) => findFirst(args),
},
},
}));
vi.mock("@/lib/tz/resolver", () => ({
resolveUserTimezone: () => Promise.resolve("UTC"),
}));
import {
COACH_ALL_TIME_TABLE_DAYS,
COACH_DAILY_DETAIL_DAYS,
COACH_HISTORY_WINDOWS,
HISTORY_REACH_DAYS,
UNBOUNDED_REACH,
capDays,
clampWindow,
fitsReach,
lookbackText,
reachCacheToken,
reachFromPrefs,
withinReach,
} from "@/lib/ai/coach/history-reach";
import { windowToDays } from "@/lib/ai/coach/snapshot-series";
import { resolveTableRange } from "@/lib/ai/coach/results/metric-table-tool";
import { snapshotCacheKey } from "@/lib/ai/coach/snapshot-cache";
import { coachDefaultWindowEnum } from "@/lib/validations/coach-prefs";
import { buildToolModeAddendum } from "@/lib/ai/coach/tools/system-addendum";
import { resolveEmptyRead } from "@/lib/ai/coach/tools/availability";
import {
renderDataInventory,
type CoachDataInventory,
} from "@/lib/ai/coach/tools/inventory";
const NOW = new Date("2026-10-04T12:00:00Z");
const NINETY = reachFromPrefs({ defaultWindow: "last90days" });
describe("the lookback arithmetic", () => {
it("offers exactly the stored enum, narrowest first, all time last", () => {
expect([...COACH_HISTORY_WINDOWS]).toEqual(coachDefaultWindowEnum.options);
expect(COACH_HISTORY_WINDOWS.at(-1)).toBe("allTime");
expect(reachFromPrefs({ defaultWindow: "allTime" })).toEqual(
UNBOUNDED_REACH,
);
expect(reachFromPrefs(null)).toEqual(UNBOUNDED_REACH);
});
it("clamps a wider window to the limit and leaves a narrower one", () => {
expect(clampWindow("allTime", NINETY)).toBe("last90days");
expect(clampWindow("lastYear", NINETY)).toBe("last90days");
expect(clampWindow("last7days", NINETY)).toBe("last7days");
expect(clampWindow("allTime", UNBOUNDED_REACH)).toBe("allTime");
});
it("shortens a fixed window and drops a block that does not fit", () => {
expect(capDays(180, NINETY)).toBe(90);
expect(capDays(30, NINETY)).toBe(30);
expect(capDays(180, UNBOUNDED_REACH)).toBe(180);
expect(fitsReach(42, NINETY)).toBe(true);
expect(fitsReach(365, NINETY)).toBe(false);
expect(fitsReach(10_000, UNBOUNDED_REACH)).toBe(true);
expect(
withinReach(new Date(NOW.getTime() - 89 * 86_400_000), NINETY, NOW),
).toBe(true);
expect(
withinReach(new Date(NOW.getTime() - 91 * 86_400_000), NINETY, NOW),
).toBe(false);
});
it("keys a limited snapshot apart and leaves an unlimited key as it was", () => {
const scope = { window: "last30days" as const, sources: ["bp" as const] };
expect(snapshotCacheKey("u1", scope)).toBe("u1|last30days|bp");
expect(snapshotCacheKey("u1", scope, UNBOUNDED_REACH)).toBe(
"u1|last30days|bp",
);
expect(snapshotCacheKey("u1", scope, NINETY)).toBe(
"u1|last30days|bp|reach90",
);
expect(reachCacheToken(undefined)).toBe("");
});
});
describe("the numbers the settings text states", () => {
it("are the numbers the readers use", () => {
for (const window of ["last7days", "last30days", "last90days"] as const) {
expect(HISTORY_REACH_DAYS[window]).toBe(windowToDays(window));
}
expect(HISTORY_REACH_DAYS.lastYear).toBe(windowToDays("lastYear"));
// All time reads a year in daily detail ...
expect(windowToDays("allTime")).toBe(COACH_DAILY_DETAIL_DAYS);
// ... and an all-time table reaches back the stated number of years.
const range = resolveTableRange({
window: "allTime",
period: "current",
timeZone: "UTC",
now: NOW,
});
const spanDays =
Math.floor((NOW.getTime() - range.from.getTime()) / 86_400_000) + 1;
expect(spanDays).toBe(COACH_ALL_TIME_TABLE_DAYS);
});
it("phrases each option with those numbers", () => {
expect(lookbackText("last90days")).toEqual({
optionParams: { days: 90 },
detailKey: "detailDays",
detailParams: { days: 90 },
});
expect(lookbackText("lastYear").detailParams).toEqual({ months: 12 });
expect(lookbackText("allTime").detailParams).toEqual({
dailyMonths: 12,
years: 10,
labMonths: 12,
});
});
});
describe("a history beyond the limit", () => {
beforeEach(() => {
groupBy.mockReset();
findFirst.mockReset();
});
it("is reported as outside_reach with no figure from it", async () => {
groupBy.mockResolvedValue([]);
findFirst.mockResolvedValue({ id: "old-row" });
const result = await resolveEmptyRead({
userId: "u1",
domain: "weight",
subject: { kind: "measurement", types: ["WEIGHT"] },
searchedWindow: "last90days",
reach: NINETY,
now: NOW,
});
expect(result).toEqual({
present: false,
reason: "outside_reach",
searchedWindow: "last90days",
});
// The in-limit probe asked only for rows inside the limit; the existence
// check asked only for rows before it.
const groupWhere = groupBy.mock.calls[0][0].where;
expect(groupWhere.measuredAt.gte.getTime()).toBe(
NOW.getTime() - 90 * 86_400_000,
);
const firstWhere = findFirst.mock.calls[0][0].where;
expect(firstWhere.measuredAt.lt.getTime()).toBe(
NOW.getTime() - 90 * 86_400_000,
);
});
it("is plain no_data when nothing older exists either", async () => {
groupBy.mockResolvedValue([]);
findFirst.mockResolvedValue(null);
const result = await resolveEmptyRead({
userId: "u1",
domain: "weight",
subject: { kind: "measurement", types: ["WEIGHT"] },
searchedWindow: "last90days",
reach: NINETY,
now: NOW,
});
expect(result.reason).toBe("no_data");
});
it("is not looked for without a limit (MCP, the default)", async () => {
groupBy.mockResolvedValue([]);
const result = await resolveEmptyRead({
userId: "u1",
domain: "weight",
subject: { kind: "measurement", types: ["WEIGHT"] },
searchedWindow: "last90days",
now: NOW,
});
expect(result.reason).toBe("no_data");
expect(findFirst).not.toHaveBeenCalled();
expect(groupBy.mock.calls[0][0].where.measuredAt).toBeUndefined();
});
});
describe("what the model is told", () => {
it("gives outside_reach its own rule, in English and German", () => {
for (const locale of ["en", "de"] as const) {
const text = buildToolModeAddendum(locale);
expect(text).toMatch(/"outside_reach"|„outside_reach"/);
}
expect(buildToolModeAddendum("en")).toContain("Coach settings");
expect(buildToolModeAddendum("de")).toContain("Coach-Einstellungen");
});
it("marks a domain beyond the limit in the inventory without figures", () => {
const inventory: CoachDataInventory = {
entries: [
{
tool: "get_metric_series",
metric: "weight",
domain: "weight",
present: false,
availability: { state: "outside_reach", reachableWithWindow: null },
},
],
restMode: false,
cycleEnabled: false,
window: "last90days",
lookbackLimit: "last90days",
probeScope: { window: "last90days" },
};
const text = renderDataInventory(inventory);
expect(text).toContain("Lookback limit: last90days.");
const line = text.split("\n").find((l) => l.startsWith("- weight:"));
expect(line).toBe(
'- weight: BEYOND LOOKBACK → get_metric_series (metric:"weight")',
);
});
});
@@ -304,3 +304,42 @@ describe("deriveTension (pure)", () => {
expect(fired!.clinicalOverride).toBe(true);
});
});
describe("buildDerivedSnapshotBlock under the lookback limit", () => {
it("leaves out a metric whose own window does not fit, never shortening it", async () => {
// Readiness reads 30 days. Under a 14-day limit it is left out rather
// than computed over 14 days, where it would disagree with the ring.
compute.mockImplementation(async (args?: { metric?: string }) =>
args?.metric === "READINESS" || args?.metric === "STRESS_SCORE"
? ok({ score: 60, band: "yellow" })
: insufficient,
);
const block = await buildDerivedSnapshotBlock("u1", PROFILE, NOW, "UTC", {
window: "last30days",
days: 14,
});
const computed = compute.mock.calls.map(
([args]) => (args as { metric: string }).metric,
);
expect(computed).not.toContain("READINESS");
expect(computed).not.toContain("SLEEP_SCORE");
expect(computed).not.toContain("VASCULAR_AGE_DELTA");
expect(computed).toContain("STRESS_SCORE");
for (const [args] of compute.mock.calls) {
const windowDays = (args as { windowDays?: number }).windowDays;
expect(windowDays === undefined || windowDays >= 14).toBe(true);
}
expect(block?.READINESS).toBeUndefined();
expect(block?.STRESS_SCORE).toBeDefined();
});
it("computes nothing under a 7-day limit, where no metric fits", async () => {
compute.mockResolvedValue(ok({ score: 60, band: "yellow" }));
const block = await buildDerivedSnapshotBlock("u1", PROFILE, NOW, "UTC", {
window: "last7days",
days: 7,
});
expect(compute).not.toHaveBeenCalled();
expect(block).toBeNull();
});
});
@@ -332,3 +332,66 @@ describe("buildGlp1SnapshotBlock injection days in the user's zone", () => {
});
});
});
describe("buildGlp1SnapshotBlock under the lookback limit", () => {
it("leaves dose changes before the limit out and keeps the current dose", async () => {
const now = new Date("2026-10-04T12:00:00.000Z");
prismaMock.medication.findMany.mockResolvedValue([
fakeMedication({
doseChanges: [
{
doseValue: 2.5,
doseUnit: "mg",
effectiveFrom: new Date("2026-06-01T00:00:00.000Z"),
note: "Nausea in the first week",
},
{
doseValue: 5,
doseUnit: "mg",
effectiveFrom: new Date("2026-08-01T00:00:00.000Z"),
note: "Stepped up after review",
},
],
}),
]);
const out = await buildGlp1SnapshotBlock("user-1", now, "UTC", {
window: "last30days",
days: 30,
});
const med = out?.medications[0];
// The current dose is therapy state, not history.
expect(med?.currentDose).toMatchObject({ value: 5, since: "2026-08-01" });
// Both changes lie before the 30-day floor: neither they nor their
// notes reach the prompt.
expect(med?.doseHistory).toEqual([]);
expect(JSON.stringify(out)).not.toContain("Nausea");
expect(JSON.stringify(out)).not.toContain("Stepped up");
});
it("keeps the dose changes inside the limit", async () => {
const now = new Date("2026-10-04T12:00:00.000Z");
prismaMock.medication.findMany.mockResolvedValue([
fakeMedication({
doseChanges: [
{
doseValue: 5,
doseUnit: "mg",
effectiveFrom: new Date("2026-08-01T00:00:00.000Z"),
note: null,
},
{
doseValue: 7.5,
doseUnit: "mg",
effectiveFrom: new Date("2026-09-20T00:00:00.000Z"),
note: null,
},
],
}),
]);
const out = await buildGlp1SnapshotBlock("user-1", now, "UTC", {
window: "last30days",
days: 30,
});
expect(out?.medications[0].doseHistory.map((d) => d.value)).toEqual([7.5]);
});
});
@@ -19,6 +19,7 @@ vi.mock("@/lib/ai/provider-runner", () => ({
import { HARD_CAP, runCoachToolLoop } from "@/lib/ai/coach/tools/loop";
import { COACH_TOOL_DEFS } from "@/lib/ai/coach/tools/definitions";
import { UNBOUNDED_REACH } from "@/lib/ai/coach/history-reach";
type Call = { id: string; name: string; arguments: string };
@@ -44,6 +45,7 @@ const baseArgs = {
system: "sys",
messages: [{ role: "user" as const, content: "q" }],
tools: COACH_TOOL_DEFS,
reach: UNBOUNDED_REACH,
};
beforeEach(() => {
@@ -32,6 +32,7 @@ vi.mock("@/lib/ai/provider-runner", () => ({
import { runCoachToolLoop } from "@/lib/ai/coach/tools/loop";
import { COACH_TOOL_DEFS } from "@/lib/ai/coach/tools/definitions";
import { UNBOUNDED_REACH } from "@/lib/ai/coach/history-reach";
const baseArgs = {
userId: "u1",
@@ -39,6 +40,7 @@ const baseArgs = {
system: "sys",
messages: [{ role: "user" as const, content: "how did I sleep?" }],
tools: COACH_TOOL_DEFS,
reach: UNBOUNDED_REACH,
};
describe("coach tool loop — which results reach the grounding guard", () => {
@@ -26,6 +26,7 @@ import {
MAX_ROUNDS,
} from "@/lib/ai/coach/tools/loop";
import { COACH_TOOL_DEFS } from "@/lib/ai/coach/tools/definitions";
import { UNBOUNDED_REACH } from "@/lib/ai/coach/history-reach";
function completion(opts: {
content: string;
@@ -53,6 +54,7 @@ const baseArgs = {
system: "sys",
messages: [{ role: "user" as const, content: "how is my bp?" }],
tools: COACH_TOOL_DEFS,
reach: UNBOUNDED_REACH,
};
describe("runCoachToolLoop", () => {
@@ -73,11 +73,14 @@ describe("workout evidence builder", () => {
durationSec: 2400,
storedSamples: [{ tSec: 0, mean: 120 }],
});
// No limit passed: the builder hands the unlimited reach on, and
// `buildSportContext` decides whether its 180 days fit.
expect(buildSportContext).toHaveBeenCalledWith(
"u1",
"running",
{ workouts: ["APPLE_HEALTH"] },
"w1",
{ window: "allTime", days: null },
);
expect(buildEvidence).toHaveBeenCalledWith(
expect.objectContaining({
+4 -1
View File
@@ -18,6 +18,7 @@
* Server-only — `readCoachCorrelations` reads `@/lib/db`. Fail-soft: a null
* return (no surviving driver / read hiccup) attaches nothing.
*/
import { UNBOUNDED_REACH, type CoachHistoryReach } from "./history-reach";
import type { Locale } from "@/lib/i18n/config";
import {
readCoachCorrelations,
@@ -53,8 +54,10 @@ export async function buildCorrelationsSnapshotBlock(
* the locale, so it says it.
*/
locale: Locale,
/** The Coach's lookback limit, applied to the scan window. */
reach: CoachHistoryReach = UNBOUNDED_REACH,
): Promise<CorrelationsSnapshotBlock | null> {
const result = await readCoachCorrelations(userId, locale);
const result = await readCoachCorrelations(userId, locale, { reach });
if (!result.present || !result.drivers || result.drivers.length === 0) {
return null;
}
+50 -6
View File
@@ -22,6 +22,11 @@ import {
type BaselineProfile,
} from "@/lib/insights/derived";
import { prisma } from "@/lib/db";
import {
UNBOUNDED_REACH,
fitsReach,
type CoachHistoryReach,
} from "./history-reach";
/**
* Trailing window the recovery dedup probes for a WHOOP-native row — matches
@@ -30,6 +35,28 @@ import { prisma } from "@/lib/db";
*/
const RECOVERY_DEDUP_WINDOW_DAYS = 14;
/**
* The window each engine reads (its own `DEFAULT_WINDOW_DAYS`). Under a
* lookback limit a metric whose window does not fit inside it is left out,
* never computed over a shortened window: a 7-day readiness would disagree
* with the 30-day ring the dashboard shows under the same name.
*/
const DERIVED_DEFAULT_WINDOW_DAYS: Readonly<
Partial<Record<DerivedMetricId, number>>
> = {
READINESS: 30,
RECOVERY_SCORE: 14,
STRESS_SCORE: 14,
STRAIN_SCORE: 14,
SLEEP_SCORE: 30,
HRV_BALANCE: 30,
FITNESS_AGE: 180,
VASCULAR_AGE_DELTA: 365,
};
/** The coincident-deviation flag's own default window. */
const COINCIDENT_DEFAULT_WINDOW_DAYS = 30;
/** The high-signal derived metrics worth a Coach prompt slot. The vitals
* baseline is omitted — the per-vital aggregate block already carries those
* numbers; here we add the composites + scores the aggregates can't express. */
@@ -226,8 +253,15 @@ export async function buildDerivedSnapshotBlock(
profile: BaselineProfile,
now: Date,
tz?: string,
reach: CoachHistoryReach = UNBOUNDED_REACH,
): Promise<DerivedSnapshotBlock | null> {
const block: Record<string, DerivedSnapshotEntry> = {};
// Every engine keeps its own window; one that does not fit is not run.
const fits = (metric: DerivedMetricId) =>
fitsReach(
DERIVED_DEFAULT_WINDOW_DAYS[metric] ?? COINCIDENT_DEFAULT_WINDOW_DAYS,
reach,
);
// v1.21.0 (C3 / D3) — the coincident-deviation flag, fired-only. Computed
// off the one shared profile alongside the scores; fail-soft to null so a
@@ -235,21 +269,29 @@ export async function buildDerivedSnapshotBlock(
// FIRED (≥2 vitals out of band today) — a quiet day adds no entry, keeping
// the snapshot noise-free. D2-8: pass the user's tz so the "today" grouping
// matches the user's calendar day, not UTC's.
const coincidentPromise = computeCoincidentDeviation(userId, profile, {
now,
...(tz ? { tz } : {}),
}).catch(() => null);
// Its 30-day baseline is left out, like any block, under a shorter limit.
const coincidentPromise = fitsReach(COINCIDENT_DEFAULT_WINDOW_DAYS, reach)
? computeCoincidentDeviation(userId, profile, {
now,
...(tz ? { tz } : {}),
}).catch(() => null)
: Promise.resolve(null);
// The metrics are independent passthrough reads off the one shared profile —
// no ordering dependency — so compute them concurrently. Per-metric fault
// isolation: a transient failure on one must never sink the whole Coach turn,
// so each compute resolves to null on throw rather than rejecting the batch.
const computed = await Promise.all(
SNAPSHOT_METRICS.map(async (metric) => {
SNAPSHOT_METRICS.filter(fits).map(async (metric) => {
try {
return {
metric,
derived: await computeDerivedMetric({ metric, userId, profile, now }),
derived: await computeDerivedMetric({
metric,
userId,
profile,
now,
}),
};
} catch {
return { metric, derived: null };
@@ -302,6 +344,8 @@ export async function buildDerivedSnapshotBlock(
source: "WHOOP",
deletedAt: null,
measuredAt: {
// Both lines exist only when their windows fit, so this 14-day
// probe lies inside the limit too.
gte: new Date(
now.getTime() - RECOVERY_DEDUP_WINDOW_DAYS * 24 * 60 * 60 * 1000,
),
Loaded 100 of 169 files, more files were not shown because too many files have changed in this diff. Show more