mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
125b15bfb7 | ||
|
|
b184a4bbac | ||
|
|
15b2f6f4de | ||
|
|
316e52dee3 | ||
|
|
dce53291a4 | ||
|
|
352a36fa47 | ||
|
|
dd72634791 | ||
|
|
3df5754ab1 | ||
|
|
42e4292683 | ||
|
|
a3efedfb07 | ||
|
|
4d6c342827 | ||
|
|
5bbc915073 | ||
|
|
305765194e | ||
|
|
1acd41eb58 | ||
|
|
49ce23809a | ||
|
|
5361239ae5 | ||
|
|
8fd7e9f7a2 | ||
|
|
bd7a354ddd | ||
|
|
fac55720a4 | ||
|
|
27b94a6bf9 | ||
|
|
e406b9c7ab | ||
|
|
c7d659eb11 | ||
|
|
c56a05925c | ||
|
|
072c1e9394 | ||
|
|
36d48bb135 | ||
|
|
3b5fb1e900 | ||
|
|
7b36b09f1f | ||
|
|
c95dcb34db | ||
|
|
11bafd68b5 | ||
|
|
4298207500 | ||
|
|
df163123e3 | ||
|
|
3e968f03a9 | ||
|
|
362818418e | ||
|
|
e59fef2545 | ||
|
|
8f9c443d35 | ||
|
|
c7844c3bc6 | ||
|
|
d403cf40af | ||
|
|
ae5cee331e | ||
|
|
85ebdad5c9 | ||
|
|
3f7a16316f | ||
|
|
8e7fd0b873 | ||
|
|
2f5877cf97 | ||
|
|
de5b315999 | ||
|
|
4dd2a2b6bc | ||
|
|
656d8b8563 | ||
|
|
fadb2ff6ed | ||
|
|
df0689ffbe | ||
|
|
adb4cd5c40 | ||
|
|
74e68745c1 | ||
|
|
8f58cad7f3 | ||
|
|
f46e0960fb | ||
|
|
88b2bf13b0 | ||
|
|
8e74bc05c8 | ||
|
|
0ba698c54f | ||
|
|
912558b96a | ||
|
|
acec87f02c | ||
|
|
6112cfc6d5 | ||
|
|
34afa3a4f0 | ||
|
|
edf6fffc32 | ||
|
|
0281883247 | ||
|
|
6b1bafca3c | ||
|
|
d87b8204f3 | ||
|
|
8d23c79ef5 | ||
|
|
cf68055cb2 | ||
|
|
210becb28b | ||
|
|
f269100b01 | ||
|
|
ef9dc1ddc6 | ||
|
|
68720418e3 | ||
|
|
5328c6a534 | ||
|
|
6671276d57 | ||
|
|
63b395915e | ||
|
|
7c7576423f | ||
|
|
43361bd74e | ||
|
|
79e0eca023 | ||
|
|
143a39e369 | ||
|
|
f6b0131731 | ||
|
|
491d542f58 | ||
|
|
756c3fb845 | ||
|
|
14a2931a99 | ||
|
|
73ce721ff2 | ||
|
|
af79f4fb5c | ||
|
|
28517affd0 | ||
|
|
ab14747013 | ||
|
|
861d1ba868 | ||
|
|
ff0769f045 | ||
|
|
ac48e0d0ed | ||
|
|
aa222f5b02 | ||
|
|
bed47387de | ||
|
|
b6ce54e2c6 | ||
|
|
6cea4be2a1 | ||
|
|
315b653079 | ||
|
|
b01c193274 | ||
|
|
61f065c651 |
No files matched your search
+180
@@ -1,5 +1,185 @@
|
||||
# Changelog
|
||||
|
||||
## [1.41.2] — 2026-10-07
|
||||
|
||||
More room for a Coach turn, prompt caching that holds across its rounds, and
|
||||
MCP tool results that match their schemas.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Turn budget.** A turn on the person's own provider gets 165k tokens,
|
||||
200 s and 16 rounds (was 120k, 150 s, 12); a turn on the operator's budget
|
||||
55k tokens, 120 s and 8 rounds (was 40k, 90 s, 6). The final answer's
|
||||
reserve keeps its share of each (1,650 tokens), the concurrent-turn slot
|
||||
follows the longer wall time, and a turn shows up to 64 steps.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Cached input in the turn budget.** Codex requests carry a stable
|
||||
`prompt_cache_key` and session id per conversation, as the official client
|
||||
does, so later rounds of a turn read the prefix from the provider's cache
|
||||
instead of paying for it again; `api.openai.com` gets the same key, gateways
|
||||
do not. Anthropic usage now counts cache reads and cache writes in the
|
||||
turn's total (they are reported beside `input_tokens`), so a cached round is
|
||||
weighted at a tenth instead of almost nothing and a cache write in full.
|
||||
Local and OpenAI-compatible servers that report
|
||||
`prompt_tokens_details.cached_tokens` have it counted.
|
||||
- **MCP output schemas (#1170).** `list_metrics` declares the `availability`
|
||||
its rows already returned, the Coach-backed reads declare `searchedWindow`
|
||||
and `available`, and `get_intraday_pulse` declares each bucket's `min` and
|
||||
`max`. The advertised
|
||||
schemas close every object, so a strict client refused these results. Every
|
||||
tool's real output is now validated against its advertised schema under a
|
||||
strict 2020-12 validator. Thanks to @RyRyMason for #1170.
|
||||
- **Deleted readings in MCP discovery.** The `list_metrics` discovery rows and
|
||||
the `search` clinical-signal probe count live rows only: a measurement type
|
||||
whose readings were all deleted is no longer listed as present, and the
|
||||
probes use the live-rows index instead of walking every deleted row.
|
||||
|
||||
## [1.41.1] — 2026-10-07
|
||||
|
||||
A calmer thinking row in the Coach.
|
||||
|
||||
### Changed
|
||||
|
||||
- **One thinking row.** While the Coach works, a single row shows a brain
|
||||
icon and the current step in body text with a soft moving highlight and
|
||||
animated dots ("Reading blood pressure…"); no running seconds. The interim
|
||||
table line under it is gone; tables appear with the answer. Afterwards the
|
||||
row reads "Thought process · N steps", stays closed unless opened, and
|
||||
closes again when a turn finishes. Opened, the steps stand on a thin line,
|
||||
each with its own icon and the reasoning summary under its title.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Coach in the demo.** A refused chat on a demo instance says that the
|
||||
Coach doesn't answer in the demo instead of reporting an unreachable
|
||||
provider; demo refusals carry `meta.errorCode: "demo.readOnly"`.
|
||||
|
||||
## [1.41.0] — 2026-10-07
|
||||
|
||||
The Coach reasons before it answers, shows what it is doing while it works,
|
||||
asks when a question is ambiguous, and remembers what it is told.
|
||||
|
||||
### Added
|
||||
|
||||
- **Reasoning.** Codex, OpenAI, Anthropic, OpenRouter, gateways and local
|
||||
servers receive the requested reasoning in their documented dialect;
|
||||
reasoning summaries stream to the Coach, signed or encrypted reasoning state
|
||||
is carried between tool rounds and never stored, and a model that rejects a
|
||||
reasoning parameter is retried without it (the downgrade is learned per
|
||||
provider, endpoint and model for an hour, only from a 400 that names the
|
||||
parameter). Thinking depth per person under Coach settings (Off, Low,
|
||||
Medium, High; Medium by default); an operator switch and maximum under
|
||||
Admin, Assistant; turns on the operator's budget are capped at Medium.
|
||||
`/api/auth/me` publishes the resolved `coachReasoning`.
|
||||
- **Budgeted tool loop.** A turn runs against a token, time and round budget
|
||||
(own provider 120k tokens, 150 s, 12 rounds; operator 40k, 90 s, 6 rounds)
|
||||
with a no-progress brake and a reserved final round that keeps the tool
|
||||
definitions with `tool_choice: none`. Every round is reserved against the
|
||||
daily budget beforehand and settled afterwards. At most two concurrent turns
|
||||
per person.
|
||||
- **Live trail.** One quiet status line per answer (`activity` frames), never
|
||||
expanded by itself; the screened reasoning text is stored encrypted
|
||||
(`coach_messages.trail_encrypted`) and served lazily by
|
||||
`GET /api/insights/chat/{id}/messages/{messageId}/trail`.
|
||||
- **Comparisons.** `compare_series` (two periods or two metrics) and a
|
||||
`compare` chart; tables arrive as interim results before the answer.
|
||||
- **Clarifications** as suggested replies in the thread, at most one in six
|
||||
turns and three a day; a declined or limited question becomes a stated
|
||||
assumption with a reply that changes it.
|
||||
- **Memory and plans.** Every turn carries an egress-checked memory block;
|
||||
`remember_fact` saves preferences, goals and context with a visible note and
|
||||
undo, health facts only after the person agrees; `propose_plan` suggests a
|
||||
plan from a stated goal. Both are written only when the answer that shows
|
||||
them is stored. Background extraction runs once a conversation is quiet for
|
||||
30 minutes; plan progress reaches the daily briefing. The memory list in
|
||||
settings shows each fact's source and accepts edits.
|
||||
- **Background reasoning** for the daily briefing (scheduled and on request)
|
||||
and the weekly and monthly narratives; status cards never reason.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Dose-change screen.** The outbound screen blocks the dose-change class
|
||||
across all seven languages, including the person's own medication names,
|
||||
drug classes, spelled-out amounts and "extra / one more"; plan texts go
|
||||
through the same screen.
|
||||
- **Glucose by time of day.** The usual-range line, the MCP baseline and the
|
||||
signals of the day compare glucose with the same hours of earlier days
|
||||
instead of setting a morning reading against whole-day means.
|
||||
- **Usual-range line** names the date of a latest reading that is not from
|
||||
today, and a steady value as one figure.
|
||||
- **Memory refresh** asks the Coach capability before deferring, so a
|
||||
switched-off Coach does not re-queue it.
|
||||
- **Dependencies.** The MCP SDK moves to 1.32 (GHSA-6qxp-vccf-f47h) and
|
||||
`proxy-addr` to 2.0.8 (GHSA-jqcg-44mw-7w3h).
|
||||
|
||||
### Changed
|
||||
|
||||
- **Answer voice.** The first sentence answers; 40 to 120 words; at most one
|
||||
next step.
|
||||
- **Keyboard focus** is drawn in a neutral tone everywhere.
|
||||
|
||||
### Migrations
|
||||
|
||||
- `0376_ai_reasoning_controls`, `0377_coach_message_trail`,
|
||||
`0378_coach_fact_source`: additive columns with constant defaults or
|
||||
nullable; no table rewrite.
|
||||
|
||||
## [1.40.6] — 2026-10-05
|
||||
|
||||
The Coach reads long series again; "today" only from today's readings; the
|
||||
dashboard card fits a phone; text fields lose the purple focus ring.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Coach series without numbers.** Since 1.21.0 every Coach tool read one
|
||||
shared per-turn snapshot capped at 24,000 characters; on accounts with many
|
||||
series whole blocks became a placeholder and the tool still answered
|
||||
`present: true`. A trimmed block is now re-read for the tool's own source,
|
||||
a Coach tool's read of one source is condensed instead of emptied (a
|
||||
summary from the daily values with the latest week against the first,
|
||||
the newest days, monthly means, each block in its own fields), a block
|
||||
without numbers
|
||||
answers `retrieval_failed` to the Coach, and the Coach calls the table tool
|
||||
itself for long histories. MCP reads are unchanged. The step list shows
|
||||
the reading count; no stray comma on copy.
|
||||
- **"Today" from yesterday's readings.** Signals of the day, the vitals fact,
|
||||
the lead line, the health status and the morning push only speak about
|
||||
today from readings taken today in the profile timezone; a stored briefing
|
||||
from an earlier day is no longer served.
|
||||
- **MCP schemas for strict clients (#1170).** `tools/list` advertises every
|
||||
input and output schema as JSON Schema 2020-12 without a `$schema` key, so
|
||||
clients that only accept 2020-12 load the tools; tool results are unchanged.
|
||||
- **Briefing for the local day.** The first read of a local day and the first
|
||||
reading of a signal metric that day warm the briefing once (within the
|
||||
existing daily limit), so users outside Europe and readings taken in the
|
||||
morning get a current briefing; every surface resolves it the same way.
|
||||
- **Deltas** use each metric's display precision ("+34 bpm").
|
||||
|
||||
### Changed
|
||||
|
||||
- **Dashboard card on a phone.** A compact score ring at the top right beside
|
||||
the lead; Today facts as label over value, full width. Desktop unchanged.
|
||||
- **Text-field focus.** No purple ring on inputs, textareas, selects and date
|
||||
fields; focus shows as a neutral border (`--input-focus`). Invalid fields
|
||||
keep their red border.
|
||||
- **Coach panel** no longer has a search field (the full conversation list
|
||||
keeps its search).
|
||||
|
||||
## [1.40.5] — 2026-10-05
|
||||
|
||||
The Coach's conversation panel runs the full height of the window.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Coach panel.** Docked, the panel runs from the top of the window to the
|
||||
bottom, beside the top bar instead of below it, and its header line
|
||||
continues the top bar's. Shut, nothing of it remains on screen.
|
||||
- **Top bar on the Coach.** It names the page and the open conversation, and
|
||||
the panel toggle sits at its right end, against the panel.
|
||||
- **New chat.** A round button with a plus at the panel's bottom right.
|
||||
|
||||
## [1.40.4] — 2026-10-05
|
||||
|
||||
The Coach's conversation panel docks to the right edge; model and tokens
|
||||
|
||||
+1335
-28
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,524 @@
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { expect, test } from "./setup/test";
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
|
||||
import {
|
||||
FIXED_NOW,
|
||||
done,
|
||||
end,
|
||||
feed,
|
||||
installLiveCoachStream,
|
||||
posts,
|
||||
serveConversation,
|
||||
tokens,
|
||||
applyTheme,
|
||||
type StubConversation,
|
||||
} from "./setup/coach-live-stream";
|
||||
|
||||
/**
|
||||
* The thinking row of a Coach turn: one quiet row at the top of the answer.
|
||||
*
|
||||
* Contracts under test, at 1440 and 390 px, light and dark:
|
||||
*
|
||||
* 1. While the turn runs the row says what is happening right now and
|
||||
* moves with the frames: Thinking… → Fetching blood pressure… →
|
||||
* Summarising 214 readings… → Writing the answer…, with the words
|
||||
* shimmering and no clock.
|
||||
* 2. A table the turn has read shows nothing of its own while the turn
|
||||
* runs: no second row, no preview, no sparkline.
|
||||
* 3. Nothing opens by itself: the row stays closed (`aria-expanded`
|
||||
* false) in every state, after the answer and after a reload; a row
|
||||
* opened during the run closes when the answer is done.
|
||||
* 4. After the answer the row reads "Thought process · 4 steps" and the
|
||||
* table the answer points at renders as its chart.
|
||||
* 5. A tap opens the steps; a reasoning round shows its summary under
|
||||
* its title.
|
||||
* 6. No horizontal scroll, a 44 px tap target on phones, axe clean.
|
||||
*
|
||||
* The chat stream is fed frame by frame from the spec
|
||||
* (`installLiveCoachStream`), so every live state is observed rather than
|
||||
* flashing by. Set COACH_SCREENSHOTS_DIR to also write screenshots of each
|
||||
* state; those runs keep motion on so the shimmer shows mid-sweep.
|
||||
*/
|
||||
|
||||
const CONVERSATION_ID = "coach-activity-e2e";
|
||||
const QUESTION = "How was my blood pressure this month?";
|
||||
const ANSWER =
|
||||
"Your blood pressure held a steady band this month, around 126/82 mmHg on most days. Nothing stands out against your usual range.";
|
||||
|
||||
const BP_VALUES: Array<[number, number]> = [
|
||||
[128, 84],
|
||||
[125, 82],
|
||||
[131, 85],
|
||||
[122, 80],
|
||||
[124, 81],
|
||||
[127, 83],
|
||||
[121, 79],
|
||||
[126, 82],
|
||||
[123, 80],
|
||||
[126, 81],
|
||||
];
|
||||
|
||||
const BP_TABLE = {
|
||||
ref: "r1",
|
||||
source: {
|
||||
tool: "get_metric_table",
|
||||
domain: "bp",
|
||||
window: "last30days",
|
||||
period: "current",
|
||||
granularity: "day",
|
||||
},
|
||||
shape: "timeSeries",
|
||||
titleKey: "coach.result.title.byDay",
|
||||
title: "Blood pressure by day",
|
||||
rowCount: BP_VALUES.length,
|
||||
chartKind: "line",
|
||||
displayed: false,
|
||||
columns: [
|
||||
{
|
||||
key: "day",
|
||||
kind: "period",
|
||||
labelKey: "coach.result.column.day",
|
||||
label: "Day",
|
||||
},
|
||||
{
|
||||
key: "systolic",
|
||||
kind: "number",
|
||||
labelKey: "coach.result.column.systolic",
|
||||
label: "Systolic",
|
||||
unit: "mmHg",
|
||||
decimals: 0,
|
||||
},
|
||||
{
|
||||
key: "diastolic",
|
||||
kind: "number",
|
||||
labelKey: "coach.result.column.diastolic",
|
||||
label: "Diastolic",
|
||||
unit: "mmHg",
|
||||
decimals: 0,
|
||||
},
|
||||
{
|
||||
key: "readings",
|
||||
kind: "count",
|
||||
labelKey: "coach.result.column.readings",
|
||||
label: "Readings",
|
||||
},
|
||||
],
|
||||
rows: BP_VALUES.map(([sys, dia], i) => [
|
||||
new Date(Date.UTC(2026, 8, 7 + i)).toISOString().slice(0, 10),
|
||||
sys,
|
||||
dia,
|
||||
i === 0 ? 205 : 1,
|
||||
]),
|
||||
truncated: false,
|
||||
chart: { kind: "line", x: "day", series: ["systolic", "diastolic"] },
|
||||
};
|
||||
const BP_SHOWN = { ...BP_TABLE, displayed: true };
|
||||
const BP_META = {
|
||||
ref: "r1",
|
||||
source: BP_TABLE.source,
|
||||
shape: BP_TABLE.shape,
|
||||
titleKey: BP_TABLE.titleKey,
|
||||
title: BP_TABLE.title,
|
||||
rowCount: BP_TABLE.rowCount,
|
||||
chartKind: "line",
|
||||
displayed: true,
|
||||
};
|
||||
|
||||
const STEP = {
|
||||
id: "s1",
|
||||
tool: "get_metric_table",
|
||||
labelKey: "coach.step.readWindow",
|
||||
label: "Checking: Blood pressure, last 30 days",
|
||||
domain: "bp",
|
||||
window: "last30days",
|
||||
granularity: "day",
|
||||
};
|
||||
|
||||
function entry(
|
||||
id: string,
|
||||
phase: string,
|
||||
status: string,
|
||||
label: string,
|
||||
extra: Record<string, unknown> = {},
|
||||
) {
|
||||
return {
|
||||
type: "activity",
|
||||
activity: {
|
||||
id,
|
||||
phase,
|
||||
status,
|
||||
round: phase === "answer" ? 2 : 1,
|
||||
labelKey: `insights.coach.activity.${phase}`,
|
||||
label,
|
||||
...extra,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const THINK_TITLE = "Looking at the whole month";
|
||||
const THINK_TEXT =
|
||||
"The month has daily readings; a daily table answers this best.";
|
||||
|
||||
const ACTIVITY_META = [
|
||||
{
|
||||
id: "a1",
|
||||
phase: "thinking",
|
||||
status: "done",
|
||||
round: 1,
|
||||
labelKey: "insights.coach.activity.thinking",
|
||||
label: "Thinking…",
|
||||
durationMs: 2_400,
|
||||
},
|
||||
{
|
||||
id: "a2",
|
||||
phase: "fetch",
|
||||
status: "done",
|
||||
round: 1,
|
||||
labelKey: "insights.coach.activity.fetching",
|
||||
label: "Fetching blood pressure, last 30 days…",
|
||||
stepRef: "s1",
|
||||
count: 214,
|
||||
durationMs: 900,
|
||||
},
|
||||
{
|
||||
id: "a3",
|
||||
phase: "digest",
|
||||
status: "done",
|
||||
round: 1,
|
||||
labelKey: "insights.coach.activity.digestDone",
|
||||
label: "214 readings from 1 area",
|
||||
count: 214,
|
||||
durationMs: 400,
|
||||
},
|
||||
{
|
||||
id: "a4",
|
||||
phase: "answer",
|
||||
status: "done",
|
||||
round: 2,
|
||||
labelKey: "insights.coach.activity.answer",
|
||||
label: "Writing the answer…",
|
||||
durationMs: 3_000,
|
||||
},
|
||||
];
|
||||
|
||||
const METRIC_SOURCE = {
|
||||
windows: ["last30days"],
|
||||
metrics: ["bp"],
|
||||
counts: { bp: 214 },
|
||||
steps: [{ ...STEP, status: "done", count: 214, resultRef: "r1" }],
|
||||
activity: ACTIVITY_META,
|
||||
results: [BP_META],
|
||||
method: {
|
||||
entries: [
|
||||
{
|
||||
domain: "bp",
|
||||
window: "last30days",
|
||||
granularity: "day",
|
||||
count: 214,
|
||||
aggregation: "mean",
|
||||
},
|
||||
],
|
||||
text: "Blood pressure, last 30 days, 214 readings, daily averages.",
|
||||
},
|
||||
};
|
||||
|
||||
function conversation(): StubConversation {
|
||||
return {
|
||||
id: CONVERSATION_ID,
|
||||
title: "Blood pressure this month",
|
||||
messages: [],
|
||||
results: { "coach-activity-e2e-a1": [BP_SHOWN] },
|
||||
trails: {
|
||||
"coach-activity-e2e-a1": {
|
||||
entries: [{ id: "a1", title: THINK_TITLE, text: THINK_TEXT }],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const VIEWPORTS = [
|
||||
{ name: "1440", width: 1440, height: 900 },
|
||||
{ name: "390", width: 390, height: 844 },
|
||||
] as const;
|
||||
|
||||
const SHOTS = process.env.COACH_SCREENSHOTS_DIR;
|
||||
|
||||
async function shot(page: Page, name: string) {
|
||||
if (!SHOTS) return;
|
||||
mkdirSync(SHOTS, { recursive: true });
|
||||
// The pointer off the row and the open/rotate transitions done, so the
|
||||
// picture shows the resting state; the shimmer keeps running.
|
||||
await page.mouse.move(0, 0);
|
||||
await page.waitForTimeout(400);
|
||||
await page.screenshot({ path: join(SHOTS, `${name}.png`) });
|
||||
}
|
||||
|
||||
async function noHorizontalScroll(page: Page) {
|
||||
const overflow = await page.evaluate(
|
||||
() => document.documentElement.scrollWidth - window.innerWidth,
|
||||
);
|
||||
expect(overflow).toBeLessThanOrEqual(0);
|
||||
}
|
||||
|
||||
test.describe("Coach live trail", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
for (const viewport of VIEWPORTS) {
|
||||
for (const theme of ["light", "dark"] as const) {
|
||||
test(`${viewport.name} px, ${theme}: one quiet line that never opens by itself`, async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name !== "chromium-desktop",
|
||||
"sets its own viewports; one project suffices",
|
||||
);
|
||||
const label = `${viewport.name}-${theme}`;
|
||||
await page.setViewportSize(viewport);
|
||||
await page.clock.setFixedTime(FIXED_NOW);
|
||||
await serveAiBlock(page, aiBlockAvailable());
|
||||
const stub = conversation();
|
||||
await serveConversation(page, stub);
|
||||
await installLiveCoachStream(page);
|
||||
await applyTheme(page, theme);
|
||||
if (SHOTS) await page.emulateMedia({ reducedMotion: "no-preference" });
|
||||
|
||||
await page.goto("/coach", { waitUntil: "domcontentloaded" });
|
||||
await page.locator('[data-slot="coach-input-textarea"]').fill(QUESTION);
|
||||
await page.locator('[data-slot="coach-input-send"]').click();
|
||||
|
||||
const turn = page.locator(
|
||||
'[role="log"] [data-slot="coach-bubble-assistant"]',
|
||||
);
|
||||
const line = turn.locator('[data-slot="coach-turn-steps"]');
|
||||
const toggle = line.locator('[data-slot="coach-turn-steps-toggle"]');
|
||||
const current = line.locator('[data-slot="coach-turn-steps-active"]');
|
||||
const chevron = line.locator('[data-slot="coach-turn-steps-chevron"]');
|
||||
const column = turn.locator('[data-slot="coach-answer-column"]');
|
||||
|
||||
// 1. Before any frame: Thinking…, shimmering, nothing to open yet.
|
||||
await expect(line).toHaveAttribute("data-state", "running");
|
||||
await expect(current).toHaveAttribute("data-text", "Thinking…");
|
||||
await expect(current.locator(".text-shimmer")).toHaveText("Thinking");
|
||||
await expect(current.locator(".waiting-dot")).toHaveCount(3);
|
||||
await expect(chevron).toHaveCount(0);
|
||||
await expect(toggle).toHaveCount(0);
|
||||
|
||||
await feed(page, [entry("a1", "thinking", "running", "Thinking…")]);
|
||||
await feed(page, [
|
||||
entry("a1", "thinking", "running", "Thinking…", {
|
||||
title: THINK_TITLE,
|
||||
}),
|
||||
]);
|
||||
await expect(current).toHaveAttribute("data-text", `${THINK_TITLE}…`);
|
||||
await expect(chevron).toBeVisible();
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||
|
||||
await feed(page, [
|
||||
entry("a1", "thinking", "done", "Thinking…", {
|
||||
title: THINK_TITLE,
|
||||
text: THINK_TEXT,
|
||||
durationMs: 2_400,
|
||||
}),
|
||||
{ type: "step", step: { ...STEP, status: "running" } },
|
||||
entry(
|
||||
"a2",
|
||||
"fetch",
|
||||
"running",
|
||||
"Fetching blood pressure, last 30 days…",
|
||||
{
|
||||
stepRef: "s1",
|
||||
},
|
||||
),
|
||||
]);
|
||||
await expect(current).toHaveAttribute(
|
||||
"data-text",
|
||||
"Fetching blood pressure, last 30 days…",
|
||||
);
|
||||
|
||||
// 2. The table it read shows nothing of its own while the turn runs.
|
||||
await feed(page, [
|
||||
{ type: "result", result: BP_TABLE, interim: true },
|
||||
{ type: "step", step: { ...STEP, status: "done", count: 214 } },
|
||||
entry(
|
||||
"a2",
|
||||
"fetch",
|
||||
"done",
|
||||
"Fetching blood pressure, last 30 days…",
|
||||
{
|
||||
stepRef: "s1",
|
||||
count: 214,
|
||||
durationMs: 900,
|
||||
},
|
||||
),
|
||||
entry("a3", "digest", "running", "Summarising 214 readings…", {
|
||||
count: 214,
|
||||
}),
|
||||
]);
|
||||
await expect(current).toHaveAttribute(
|
||||
"data-text",
|
||||
"Summarising 214 readings…",
|
||||
);
|
||||
// One row and nothing else in the answer's column: no table title,
|
||||
// no sparkline, no clock.
|
||||
await expect(column.locator(":scope > *")).toHaveCount(1);
|
||||
await expect(turn).not.toContainText("Blood pressure by day");
|
||||
await expect(turn.locator("svg polyline")).toHaveCount(0);
|
||||
await expect(line).not.toContainText(/\d\s?s\b/);
|
||||
await expect(
|
||||
turn.locator('[data-slot="coach-answer-bubble"]'),
|
||||
).toHaveCount(0);
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||
await noHorizontalScroll(page);
|
||||
await shot(page, `${label}-1-running`);
|
||||
|
||||
// A tap opens the steps while the turn runs...
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "true");
|
||||
await expect(line.locator('[data-slot="coach-turn-step"]')).toHaveCount(
|
||||
3,
|
||||
);
|
||||
await expect(
|
||||
line.locator('[data-slot="coach-turn-step"]').nth(2),
|
||||
).toHaveAttribute("data-status", "running");
|
||||
await noHorizontalScroll(page);
|
||||
await shot(page, `${label}-2-running-open`);
|
||||
|
||||
await feed(page, [
|
||||
entry("a3", "digest", "done", "214 readings from 1 area", {
|
||||
count: 214,
|
||||
durationMs: 400,
|
||||
}),
|
||||
entry("a4", "answer", "running", "Writing the answer…"),
|
||||
...tokens(ANSWER),
|
||||
]);
|
||||
await expect(current).toHaveAttribute(
|
||||
"data-text",
|
||||
"Writing the answer…",
|
||||
);
|
||||
await expect(
|
||||
turn.locator('[data-slot="coach-answer-bubble"]'),
|
||||
).toContainText(ANSWER);
|
||||
// ...and stays as the person left it until the turn ends.
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "true");
|
||||
|
||||
// The persisted copy lands with the refetch after `done`.
|
||||
stub.messages.push(
|
||||
{ id: "coach-activity-e2e-u1", role: "user", content: QUESTION },
|
||||
{
|
||||
id: "coach-activity-e2e-a1",
|
||||
role: "assistant",
|
||||
content: ANSWER,
|
||||
metricSource: METRIC_SOURCE,
|
||||
},
|
||||
);
|
||||
await feed(page, [
|
||||
entry("a4", "answer", "done", "Writing the answer…", {
|
||||
durationMs: 3_000,
|
||||
}),
|
||||
{ type: "provenance", metricSource: METRIC_SOURCE },
|
||||
{ type: "result", result: BP_SHOWN },
|
||||
done(CONVERSATION_ID, "coach-activity-e2e-a1"),
|
||||
]);
|
||||
await end(page);
|
||||
|
||||
// 4. The thought process and its steps, closed again; the table the
|
||||
// answer points at is its chart.
|
||||
const bubble = page
|
||||
.locator('[data-slot="coach-bubble-assistant"]')
|
||||
.filter({ hasText: ANSWER });
|
||||
const settled = bubble.locator('[data-slot="coach-turn-steps"]');
|
||||
await expect(settled).toHaveAttribute("data-state", "done");
|
||||
await expect(
|
||||
settled.locator('[data-slot="coach-turn-steps-done"]'),
|
||||
).toHaveText("Thought process · 4 steps");
|
||||
await expect(settled.locator(".text-shimmer")).toHaveCount(0);
|
||||
const settledToggle = settled.locator(
|
||||
'[data-slot="coach-turn-steps-toggle"]',
|
||||
);
|
||||
await expect(settledToggle).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(
|
||||
settled.locator('[data-slot="coach-turn-steps-panel"]'),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
bubble.locator('[data-slot="coach-result-chart"][data-ref="r1"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
bubble.locator('[data-slot="coach-result-chart-plot"]'),
|
||||
).toBeVisible();
|
||||
expect((await posts(page))[0]).toMatchObject({ message: QUESTION });
|
||||
await noHorizontalScroll(page);
|
||||
await shot(page, `${label}-3-settled`);
|
||||
|
||||
// 6. A 44 px tap target on phones.
|
||||
if (viewport.width < 640) {
|
||||
const box = await settledToggle.boundingBox();
|
||||
expect(box?.height ?? 0).toBeGreaterThanOrEqual(44);
|
||||
}
|
||||
|
||||
// 5. A tap opens the steps; the reasoning round shows its summary
|
||||
// under its title.
|
||||
await settledToggle.click();
|
||||
await expect(settledToggle).toHaveAttribute("aria-expanded", "true");
|
||||
const panel = settled.locator('[data-slot="coach-turn-steps-panel"]');
|
||||
await expect(panel).toBeVisible();
|
||||
const rows = panel.locator('[data-slot="coach-turn-step"]');
|
||||
await expect(rows).toHaveCount(4);
|
||||
await expect(rows.nth(0)).toHaveAttribute("data-phase", "thinking");
|
||||
await expect(rows.nth(1)).toHaveAttribute("data-phase", "fetch");
|
||||
await expect(
|
||||
panel.locator('[data-slot="coach-method-line"]'),
|
||||
).toContainText(METRIC_SOURCE.method.text);
|
||||
await expect(rows.nth(0)).toContainText(THINK_TITLE);
|
||||
await expect(
|
||||
rows.nth(0).locator('[data-slot="coach-turn-step-detail"]'),
|
||||
).toHaveText(THINK_TEXT);
|
||||
await expect(
|
||||
panel.locator('[data-slot="coach-turn-step-connector"]'),
|
||||
).toHaveCount(3);
|
||||
await noHorizontalScroll(page);
|
||||
await shot(page, `${label}-4-settled-open`);
|
||||
|
||||
const axe = await new AxeBuilder({ page })
|
||||
.include('[data-slot="coach-bubble-assistant"]')
|
||||
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
|
||||
.analyze();
|
||||
expect(
|
||||
axe.violations.map((v) => ({
|
||||
id: v.id,
|
||||
nodes: v.nodes.map((n) => n.target.join(" ")),
|
||||
})),
|
||||
).toEqual([]);
|
||||
|
||||
// 3. After a reload the trail is closed again, and opening it reads
|
||||
// the stored titles and texts.
|
||||
await page.goto(`/coach?c=${CONVERSATION_ID}`, {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
const reloaded = page
|
||||
.locator('[data-slot="coach-bubble-assistant"]')
|
||||
.filter({ hasText: ANSWER })
|
||||
.locator('[data-slot="coach-turn-steps"]');
|
||||
await expect(reloaded).toHaveAttribute("data-state", "done");
|
||||
await expect(
|
||||
reloaded.locator('[data-slot="coach-turn-steps-done"]'),
|
||||
).toHaveText("Thought process · 4 steps");
|
||||
const reloadedToggle = reloaded.locator(
|
||||
'[data-slot="coach-turn-steps-toggle"]',
|
||||
);
|
||||
await expect(reloadedToggle).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(
|
||||
reloaded.locator('[data-slot="coach-turn-steps-panel"]'),
|
||||
).toHaveCount(0);
|
||||
await reloadedToggle.click();
|
||||
const first = reloaded.locator('[data-slot="coach-turn-step"]').nth(0);
|
||||
await expect(first).toContainText(THINK_TITLE);
|
||||
await expect(first).toContainText(THINK_TEXT);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -18,9 +18,11 @@ import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
|
||||
* are not text on the row; the details icon shows them in a tooltip on
|
||||
* keyboard focus (desktop) and on a tap (phone); Enter and Space keep it
|
||||
* open, and a second tap or Escape closes it.
|
||||
* 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.
|
||||
* 2. The follow-up chips sit in the answer's column, right under the
|
||||
* answer and above the action row (the row is the last thing in the
|
||||
* 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
|
||||
@@ -311,7 +313,14 @@ test.describe("Coach answer layout", () => {
|
||||
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);
|
||||
// No empty band between the answer and its replies: the column's own
|
||||
// gap, whatever the hover state of the row below.
|
||||
const chipsGap = chipsBox.y - (bubbleBox.y + bubbleBox.height);
|
||||
expect(chipsGap, "the chips follow the answer").toBeGreaterThanOrEqual(0);
|
||||
expect(chipsGap, "the chips follow the answer").toBeLessThanOrEqual(12);
|
||||
expect(rowBox.y, "the action row comes last").toBeGreaterThanOrEqual(
|
||||
chipsBox.y + chipsBox.height - 1,
|
||||
);
|
||||
const answerSize = await bubble.evaluate(
|
||||
(el) => getComputedStyle(el).fontSize,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import { expect, test } from "./setup/test";
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
|
||||
import {
|
||||
FIXED_NOW,
|
||||
done,
|
||||
end,
|
||||
feed,
|
||||
installLiveCoachStream,
|
||||
posts,
|
||||
serveConversation,
|
||||
tokens,
|
||||
applyTheme,
|
||||
type StubConversation,
|
||||
} from "./setup/coach-live-stream";
|
||||
|
||||
/**
|
||||
* A clarifying question is a conversation, not a form.
|
||||
*
|
||||
* 1. The question is a normal Coach message; its choices are quiet reply
|
||||
* pills under it, the assumed one first. There is no card.
|
||||
* 2. A pill sends its label as the person's own message, with
|
||||
* `clarification: { messageId, choiceId }`.
|
||||
* 3. A typed answer works too, with `clarification: { messageId }`.
|
||||
* 4. The trail above the question stays closed.
|
||||
*/
|
||||
|
||||
const CONVERSATION_ID = "coach-clarify-e2e";
|
||||
const QUESTION = "Is my heart rate okay?";
|
||||
const ASKS =
|
||||
"Do you mean your resting heart rate or your heart rate while walking? Otherwise I'll look at your resting heart rate.";
|
||||
const ANSWER =
|
||||
"Your walking heart rate sat around 98 bpm over the last 30 days, close to your usual level.";
|
||||
|
||||
const CLARIFICATION = {
|
||||
kind: "metric",
|
||||
freeText: true,
|
||||
assumption: "c1",
|
||||
choices: [
|
||||
{
|
||||
id: "c1",
|
||||
labelKey: "insights.coach.metric.resting_hr",
|
||||
label: "Resting HR",
|
||||
value: { metric: "resting_hr" },
|
||||
},
|
||||
{
|
||||
id: "c2",
|
||||
labelKey: "insights.coach.metric.walking_hr",
|
||||
label: "Walking HR",
|
||||
value: { metric: "walking_hr" },
|
||||
},
|
||||
],
|
||||
};
|
||||
const ASK_SOURCE = {
|
||||
windows: ["last30days"],
|
||||
metrics: ["resting_hr"],
|
||||
activity: [
|
||||
{
|
||||
id: "a1",
|
||||
phase: "thinking",
|
||||
status: "done",
|
||||
round: 1,
|
||||
labelKey: "insights.coach.activity.thinking",
|
||||
label: "Thinking…",
|
||||
durationMs: 1_200,
|
||||
},
|
||||
{
|
||||
id: "a2",
|
||||
phase: "asking",
|
||||
status: "done",
|
||||
round: 1,
|
||||
labelKey: "insights.coach.activity.asking",
|
||||
label: "Asking you…",
|
||||
durationMs: 300,
|
||||
},
|
||||
],
|
||||
clarification: CLARIFICATION,
|
||||
};
|
||||
|
||||
async function ask(
|
||||
page: import("@playwright/test").Page,
|
||||
stub: StubConversation,
|
||||
) {
|
||||
await page.locator('[data-slot="coach-input-textarea"]').fill(QUESTION);
|
||||
await page.locator('[data-slot="coach-input-send"]').click();
|
||||
const live = page.locator(
|
||||
'[role="log"] [data-slot="coach-turn-steps-active"]',
|
||||
);
|
||||
await feed(page, [
|
||||
{
|
||||
type: "activity",
|
||||
activity: {
|
||||
...ASK_SOURCE.activity[0],
|
||||
status: "running",
|
||||
durationMs: undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "activity",
|
||||
activity: {
|
||||
...ASK_SOURCE.activity[1],
|
||||
status: "running",
|
||||
durationMs: undefined,
|
||||
},
|
||||
},
|
||||
]);
|
||||
await expect(live).toHaveAttribute("data-text", "Asking you…");
|
||||
stub.messages.push(
|
||||
{ id: "coach-clarify-e2e-u1", role: "user", content: QUESTION },
|
||||
{
|
||||
id: "coach-clarify-e2e-a1",
|
||||
role: "assistant",
|
||||
content: ASKS,
|
||||
metricSource: ASK_SOURCE,
|
||||
},
|
||||
);
|
||||
await feed(page, [
|
||||
...tokens(ASKS),
|
||||
{ type: "activity", activity: ASK_SOURCE.activity[1] },
|
||||
{ type: "provenance", metricSource: ASK_SOURCE },
|
||||
{ type: "clarification", clarification: CLARIFICATION },
|
||||
done(CONVERSATION_ID, "coach-clarify-e2e-a1"),
|
||||
]);
|
||||
await end(page);
|
||||
}
|
||||
|
||||
test.describe("Coach clarifying question", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
test("pills in the conversation, the assumed one first, and a tap answers", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name !== "chromium-desktop",
|
||||
"the flow is viewport-independent; the activity spec covers 390 px",
|
||||
);
|
||||
await page.clock.setFixedTime(FIXED_NOW);
|
||||
await serveAiBlock(page, aiBlockAvailable());
|
||||
const stub: StubConversation = {
|
||||
id: CONVERSATION_ID,
|
||||
title: "Heart rate",
|
||||
messages: [],
|
||||
results: {},
|
||||
trails: {},
|
||||
};
|
||||
await serveConversation(page, stub);
|
||||
await installLiveCoachStream(page);
|
||||
await applyTheme(page, "light");
|
||||
await page.goto("/coach", { waitUntil: "domcontentloaded" });
|
||||
|
||||
await ask(page, stub);
|
||||
|
||||
const asked = page
|
||||
.locator('[data-slot="coach-bubble-assistant"]')
|
||||
.filter({ hasText: ASKS });
|
||||
await expect(asked).toHaveCount(1);
|
||||
// 1. No card anywhere; the pills sit in the answer's own column.
|
||||
await expect(
|
||||
page.locator('[data-slot="coach-clarification-card"]'),
|
||||
).toHaveCount(0);
|
||||
const pills = asked.locator('[data-slot="coach-suggested-replies"]');
|
||||
await expect(pills).toBeVisible();
|
||||
await expect(pills).toHaveAttribute(
|
||||
"data-message-id",
|
||||
"coach-clarify-e2e-a1",
|
||||
);
|
||||
const choices = pills.locator("[data-choice-id]");
|
||||
await expect(choices).toHaveCount(2);
|
||||
await expect(choices.nth(0)).toHaveAttribute("data-choice-id", "c1");
|
||||
// 4. The row says an answer is needed, and stays closed.
|
||||
const row = asked.locator('[data-slot="coach-turn-steps"]');
|
||||
await expect(row).toHaveAttribute("data-state", "awaiting");
|
||||
await expect(row.locator('[data-slot="coach-turn-steps-done"]')).toHaveText(
|
||||
"Answer needed",
|
||||
);
|
||||
await expect(row.locator(".text-shimmer")).toHaveCount(0);
|
||||
await expect(
|
||||
asked.locator('[data-slot="coach-turn-steps-toggle"]'),
|
||||
).toHaveAttribute("aria-expanded", "false");
|
||||
|
||||
// 2. A tap sends the label as the person's message.
|
||||
const walking = choices.nth(1);
|
||||
const label = (await walking.textContent())?.trim() ?? "";
|
||||
await walking.click();
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-slot="coach-bubble-user"]')
|
||||
.filter({ hasText: label }),
|
||||
).toBeVisible();
|
||||
await expect(pills).toHaveCount(0);
|
||||
const sent = await posts(page);
|
||||
expect(sent[1]).toMatchObject({
|
||||
conversationId: CONVERSATION_ID,
|
||||
message: label,
|
||||
clarification: { messageId: "coach-clarify-e2e-a1", choiceId: "c2" },
|
||||
});
|
||||
|
||||
stub.messages.push(
|
||||
{ id: "coach-clarify-e2e-u2", role: "user", content: label },
|
||||
{
|
||||
id: "coach-clarify-e2e-a2",
|
||||
role: "assistant",
|
||||
content: ANSWER,
|
||||
metricSource: { windows: ["last30days"], metrics: ["walking_hr"] },
|
||||
},
|
||||
);
|
||||
await feed(page, [
|
||||
...tokens(ANSWER),
|
||||
done(CONVERSATION_ID, "coach-clarify-e2e-a2"),
|
||||
]);
|
||||
await end(page);
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-slot="coach-bubble-assistant"]')
|
||||
.filter({ hasText: ANSWER }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.locator('[data-slot="coach-suggested-replies"]'),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("a typed answer answers the question too", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name !== "chromium-desktop",
|
||||
"viewport-independent; desktop run suffices",
|
||||
);
|
||||
await page.clock.setFixedTime(FIXED_NOW);
|
||||
await serveAiBlock(page, aiBlockAvailable());
|
||||
const stub: StubConversation = {
|
||||
id: CONVERSATION_ID,
|
||||
title: "Heart rate",
|
||||
messages: [],
|
||||
results: {},
|
||||
trails: {},
|
||||
};
|
||||
await serveConversation(page, stub);
|
||||
await installLiveCoachStream(page);
|
||||
await applyTheme(page, "dark");
|
||||
await page.goto("/coach", { waitUntil: "domcontentloaded" });
|
||||
|
||||
await ask(page, stub);
|
||||
await expect(
|
||||
page.locator('[data-slot="coach-suggested-replies"]'),
|
||||
).toBeVisible();
|
||||
|
||||
await page
|
||||
.locator('[data-slot="coach-input-textarea"]')
|
||||
.fill("the walking one");
|
||||
await page.locator('[data-slot="coach-input-send"]').click();
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-slot="coach-bubble-user"]')
|
||||
.filter({ hasText: "the walking one" }),
|
||||
).toBeVisible();
|
||||
const sent = await posts(page);
|
||||
expect(sent[1]).toMatchObject({
|
||||
message: "the walking one",
|
||||
clarification: { messageId: "coach-clarify-e2e-a1" },
|
||||
});
|
||||
expect(
|
||||
(sent[1].clarification as Record<string, unknown>).choiceId,
|
||||
).toBeUndefined();
|
||||
await end(page);
|
||||
});
|
||||
});
|
||||
+21
-15
@@ -17,12 +17,13 @@ import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
|
||||
* puts the table on the clipboard.
|
||||
* 3. A follow-up chip under the latest reply sends its label with
|
||||
* `followUp: { messageId, id }`.
|
||||
* 4. A reply that ends in a clarifying question shows the choices card
|
||||
* and no chips (as the server sends it); a choice sends its label with
|
||||
* `clarification: { messageId, choiceId }`, and the card leaves once a
|
||||
* 4. A reply that ends in a clarifying question offers its choices as
|
||||
* reply pills under it and no chips (as the server sends it); there is
|
||||
* no card. A choice sends its label with
|
||||
* `clarification: { messageId, choiceId }`, and the pills leave once a
|
||||
* plain answer is the latest reply.
|
||||
* 5. axe finds nothing on the answer, the chips and the clarification
|
||||
* card, in the light and the dark theme.
|
||||
* 5. axe finds nothing on the answer, the chips and the reply pills, in
|
||||
* the light and the dark theme.
|
||||
*
|
||||
* The chat POST is a stubbed event stream in the documented frame order,
|
||||
* and the conversation detail it refetches after `done` returns the same
|
||||
@@ -441,7 +442,7 @@ async function expectNoAxeViolations(page: Page, label: string) {
|
||||
const result = await new AxeBuilder({ page })
|
||||
.include('[data-slot="coach-bubble-assistant"]')
|
||||
.include('[data-slot="coach-follow-up-chips"]')
|
||||
.include('[data-slot="coach-clarification-card"]')
|
||||
.include('[data-slot="coach-suggested-replies"]')
|
||||
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
|
||||
.analyze();
|
||||
expect(
|
||||
@@ -581,26 +582,31 @@ test.describe("Coach dialog", () => {
|
||||
});
|
||||
expect(posts[1].clarification).toBeUndefined();
|
||||
|
||||
// 4. The reply asks; the card offers the choices, and the chips of
|
||||
// the earlier reply are gone.
|
||||
const card = page.locator('[data-slot="coach-clarification-card"]');
|
||||
await expect(card).toBeVisible();
|
||||
await expect(card).toHaveAttribute(
|
||||
// 4. The reply asks; pills under it offer the choices, and the chips
|
||||
// of the earlier reply are gone. No card.
|
||||
await expect(
|
||||
page.locator('[data-slot="coach-clarification-card"]'),
|
||||
).toHaveCount(0);
|
||||
const pills = secondBubble.locator(
|
||||
'[data-slot="coach-suggested-replies"]',
|
||||
);
|
||||
await expect(pills).toBeVisible();
|
||||
await expect(pills).toHaveAttribute(
|
||||
"data-message-id",
|
||||
TURNS[1].assistantId,
|
||||
);
|
||||
await expect(card.locator("[data-choice-id]")).toHaveCount(2);
|
||||
await expect(pills.locator("[data-choice-id]")).toHaveCount(2);
|
||||
await expect(chips).toHaveCount(0);
|
||||
// The earlier result still renders under its own answer.
|
||||
await expect(figure).toBeVisible();
|
||||
|
||||
// 5. axe over the answers, the result in both views and the card.
|
||||
// 5. axe over the answers, the result in both views and the pills.
|
||||
await expectNoAxeViolations(page, `${theme} chart view with a question`);
|
||||
await figure.locator('[data-slot="coach-result-view-table"]').click();
|
||||
await expect(table).toBeVisible();
|
||||
await expectNoAxeViolations(page, `${theme} table view with a question`);
|
||||
|
||||
await card.locator('[data-choice-id="c2"]').click();
|
||||
await pills.locator('[data-choice-id="c2"]').click();
|
||||
|
||||
const thirdBubble = page
|
||||
.locator('[data-slot="coach-bubble-assistant"]')
|
||||
@@ -616,7 +622,7 @@ test.describe("Coach dialog", () => {
|
||||
expect(posts[2].followUp).toBeUndefined();
|
||||
|
||||
// A plain answer closes the question and offers no chips.
|
||||
await expect(card).toHaveCount(0);
|
||||
await expect(pills).toHaveCount(0);
|
||||
await expect(chips).toHaveCount(0);
|
||||
await expect(
|
||||
thirdBubble.locator('[data-slot="coach-turn-steps"]'),
|
||||
|
||||
+117
-27
@@ -16,7 +16,11 @@ import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
|
||||
* and the reading column (thread and composer) is exactly as wide with
|
||||
* the panel open as with it closed. The choice survives a reload. The
|
||||
* panel is flush with the viewport's right edge (no scrollbar gutter
|
||||
* beside it) and runs from the top bar to the bottom edge.
|
||||
* beside it) and runs from the top of the window to the bottom; the
|
||||
* top bar ends at its left edge and the panel's header border is the
|
||||
* top bar's. The toggle is the bar's last item, within 16 px of its
|
||||
* right edge, open and shut; shut, no pixel of the panel shows and the
|
||||
* bar runs to the window's edge. New chat is a round 48 px plus.
|
||||
* 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
|
||||
@@ -28,7 +32,7 @@ import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
|
||||
* 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
|
||||
* right edge (24 px in the docked panel); 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.
|
||||
*
|
||||
@@ -37,7 +41,10 @@ import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
|
||||
* Screenshots are attached to the report (no pixel baselines).
|
||||
*/
|
||||
|
||||
const NOW = Date.now();
|
||||
// A fixed midday, pinned in the browser too (see beforeEach): the panel groups
|
||||
// conversations by the browser's local day, so a run just after midnight in
|
||||
// the runner's or the profile's zone would move "an hour ago" into yesterday.
|
||||
const NOW = Date.parse("2026-06-04T12:00:00Z");
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
|
||||
interface Conversation {
|
||||
@@ -233,10 +240,10 @@ async function expectNoSidewaysScroll(page: Page) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The docked panel is flush with the viewport: its right edge is the
|
||||
* viewport's (no scrollbar gutter beside it), it runs from the top bar's
|
||||
* bottom edge to the bottom of the viewport, and its own border is the left
|
||||
* one only.
|
||||
* The docked panel is flush with the window: its right edge is the
|
||||
* viewport's (no scrollbar gutter beside it), it runs from the top of the
|
||||
* window to the bottom, the top bar ends at its left edge, its header row's
|
||||
* bottom border is the top bar's, and its own border is the left one only.
|
||||
*/
|
||||
async function expectPanelFlush(page: Page, width: number, height: number) {
|
||||
const geometry = await page.evaluate(() => {
|
||||
@@ -244,15 +251,22 @@ async function expectPanelFlush(page: Page, width: number, height: number) {
|
||||
'[data-slot="coach-conversations-panel"]',
|
||||
)!;
|
||||
const inner = aside.firstElementChild as HTMLElement;
|
||||
const header = aside.querySelector(
|
||||
'[data-slot="coach-conversations-panel-header"]',
|
||||
)!;
|
||||
const bar = document.querySelector('[data-slot="top-bar"]')!;
|
||||
const main = document.getElementById("main-content")!;
|
||||
const style = getComputedStyle(inner);
|
||||
const box = aside.getBoundingClientRect();
|
||||
const barBox = bar.getBoundingClientRect();
|
||||
return {
|
||||
right: box.right,
|
||||
top: box.top,
|
||||
bottom: box.bottom,
|
||||
barBottom: bar.getBoundingClientRect().bottom,
|
||||
left: box.left,
|
||||
headerBottom: header.getBoundingClientRect().bottom,
|
||||
barBottom: barBox.bottom,
|
||||
barRight: barBox.right,
|
||||
gutter: main.offsetWidth - main.clientWidth,
|
||||
borders: [
|
||||
style.borderTopWidth,
|
||||
@@ -263,12 +277,60 @@ async function expectPanelFlush(page: Page, width: number, height: number) {
|
||||
};
|
||||
});
|
||||
expect(geometry.right).toBe(width);
|
||||
expect(geometry.top).toBe(geometry.barBottom);
|
||||
expect(geometry.top).toBe(0);
|
||||
expect(geometry.bottom).toBe(height);
|
||||
expect(geometry.barRight).toBe(geometry.left);
|
||||
expect(geometry.headerBottom).toBe(geometry.barBottom);
|
||||
expect(geometry.gutter).toBe(0);
|
||||
expect(geometry.borders).toEqual(["0px", "0px", "0px", "1px"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The toggle is the last item of the top bar (the account menu that follows
|
||||
* it on phones is not rendered from `md`), and its right edge is within
|
||||
* 16 px of the bar's right edge.
|
||||
*/
|
||||
async function expectToggleAtBarEnd(page: Page) {
|
||||
const at = await page.evaluate(() => {
|
||||
const bar = document.querySelector('[data-slot="top-bar"]')!;
|
||||
const shown = [...bar.querySelectorAll("*")].filter(
|
||||
(el) => el.getClientRects().length > 0,
|
||||
);
|
||||
const toggle = bar.querySelector('[data-slot="coach-panel-toggle"]')!;
|
||||
const rightmost = Math.max(
|
||||
...shown.map((el) => el.getBoundingClientRect().right),
|
||||
);
|
||||
return {
|
||||
gap:
|
||||
bar.getBoundingClientRect().right -
|
||||
toggle.getBoundingClientRect().right,
|
||||
last: toggle.getBoundingClientRect().right >= rightmost,
|
||||
lastChild:
|
||||
toggle.closest('[data-slot="top-bar-actions"]')?.lastElementChild ===
|
||||
toggle,
|
||||
};
|
||||
});
|
||||
expect(at.last, "nothing in the bar stands right of the toggle").toBe(true);
|
||||
expect(at.lastChild).toBe(true);
|
||||
expect(at.gap).toBeGreaterThanOrEqual(0);
|
||||
expect(at.gap).toBeLessThanOrEqual(16);
|
||||
}
|
||||
|
||||
/** New chat: a round 48 px button with the plus glyph. */
|
||||
async function expectFab(page: Page) {
|
||||
const fab = panel(page).locator('[data-slot="coach-panel-new-chat"]');
|
||||
const box = (await fab.boundingBox())!;
|
||||
expect([Math.round(box.width), Math.round(box.height)]).toEqual([48, 48]);
|
||||
expect(
|
||||
await fab.evaluate((el) =>
|
||||
parseFloat(getComputedStyle(el).borderTopLeftRadius),
|
||||
),
|
||||
"round",
|
||||
).toBeGreaterThanOrEqual(24);
|
||||
await expect(fab.locator("svg.lucide-plus")).toHaveCount(1);
|
||||
await expect(fab).toHaveAttribute("aria-label", "New chat");
|
||||
}
|
||||
|
||||
/** Rendered heights of the docked panel's header buttons and first row. */
|
||||
async function panelControlHeights(page: Page) {
|
||||
const height = async (slot: string) =>
|
||||
@@ -347,6 +409,7 @@ test.describe("Coach page frame", () => {
|
||||
testInfo.project.name !== "chromium-desktop",
|
||||
"each case sets its own viewport; the desktop project runs them all",
|
||||
);
|
||||
await page.clock.setFixedTime(new Date(NOW));
|
||||
await serveAiBlock(page, aiBlockAvailable());
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
});
|
||||
@@ -374,12 +437,13 @@ test.describe("Coach page frame", () => {
|
||||
const aside = (await panel(page).boundingBox())!;
|
||||
expect(aside.x).toBeGreaterThanOrEqual(open.threadX + open.thread);
|
||||
await expectPanelFlush(page, width, 900);
|
||||
// Open, the toggle stands beside the panel's leading edge.
|
||||
const openToggle = (await toggle(page).boundingBox())!;
|
||||
expect(openToggle.x + openToggle.width).toBeLessThanOrEqual(aside.x);
|
||||
expect(aside.x - (openToggle.x + openToggle.width)).toBeLessThanOrEqual(
|
||||
24,
|
||||
);
|
||||
// Open, the toggle is the top bar's last item, against the panel.
|
||||
await expectToggleAtBarEnd(page);
|
||||
await expectFab(page);
|
||||
// The bar says where the reader is: the page, then the conversation.
|
||||
await expect(
|
||||
page.locator('[data-slot="coach-top-bar-trail"]'),
|
||||
).toHaveText(/Coach.*Blood pressure this week/);
|
||||
await expectNoSidewaysScroll(page);
|
||||
await shot(page, testInfo, `coach-frame-${width}-open`);
|
||||
|
||||
@@ -388,18 +452,31 @@ test.describe("Coach page frame", () => {
|
||||
await waitForWidth(panel(page), 0);
|
||||
await expect(toggle(page)).toHaveAttribute("aria-expanded", "false");
|
||||
const closed = await columnWidths(page);
|
||||
// Closed, it returns to the trailing edge of the top bar.
|
||||
// Closed, the panel is entirely off-screen, the top bar runs to the
|
||||
// window's edge and the toggle is still its last item.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const box = (await toggle(page).boundingBox())!;
|
||||
return Math.round(width - (box.x + box.width));
|
||||
})
|
||||
.toBeLessThanOrEqual(24);
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
document
|
||||
.querySelector('[data-slot="top-bar"]')!
|
||||
.getBoundingClientRect().right,
|
||||
),
|
||||
)
|
||||
.toBe(width);
|
||||
expect(
|
||||
await panel(page).evaluate((el) => {
|
||||
const box = el.getBoundingClientRect();
|
||||
return box.width === 0 || box.left >= window.innerWidth;
|
||||
}),
|
||||
"no pixel of the shut panel shows",
|
||||
).toBe(true);
|
||||
await expectToggleAtBarEnd(page);
|
||||
expect(Math.abs(closed.thread - open.thread)).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(closed.composer - open.composer)).toBeLessThanOrEqual(1);
|
||||
// Closed, nothing inside the panel is reachable.
|
||||
await expect(
|
||||
panel(page).locator('[data-slot="coach-history-search"]'),
|
||||
panel(page).locator('[data-slot="coach-history-select"]').first(),
|
||||
).not.toBeInViewport();
|
||||
await expectNoSidewaysScroll(page);
|
||||
await shot(page, testInfo, `coach-frame-${width}-closed`);
|
||||
@@ -411,7 +488,10 @@ test.describe("Coach page frame", () => {
|
||||
// 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 panel(page)
|
||||
.locator('[data-slot="coach-history-select"]')
|
||||
.first()
|
||||
.focus();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "closed");
|
||||
await expect(toggle(page)).toBeFocused();
|
||||
@@ -475,10 +555,14 @@ test.describe("Coach page frame", () => {
|
||||
const fab = panel(page).locator('[data-slot="coach-panel-new-chat"]');
|
||||
const fabBox = (await fab.boundingBox())!;
|
||||
const asideBox = (await panel(page).boundingBox())!;
|
||||
// 24 px in from the panel's corner from `md`, 16 px on a phone.
|
||||
expect(asideBox.x + asideBox.width - (fabBox.x + fabBox.width)).toBeCloseTo(
|
||||
16,
|
||||
24,
|
||||
0,
|
||||
);
|
||||
expect(
|
||||
asideBox.y + asideBox.height - (fabBox.y + fabBox.height),
|
||||
).toBeCloseTo(24, 0);
|
||||
await fab.click();
|
||||
await expect(page).toHaveURL(/\/coach$/);
|
||||
await expect(
|
||||
@@ -705,9 +789,15 @@ test.describe("Coach page frame", () => {
|
||||
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);
|
||||
// Measured once the sheet has slid in.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const box = (await fab.boundingBox())!;
|
||||
return Math.round(390 - (box.x + box.width));
|
||||
})
|
||||
.toBe(16);
|
||||
expect((await fab.boundingBox())!.width).toBe(48);
|
||||
await expect(fab.locator("svg.lucide-plus")).toHaveCount(1);
|
||||
await shot(page, testInfo, "coach-frame-390-panel");
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
import type { Page, Route } from "@playwright/test";
|
||||
|
||||
import { expect, test } from "./setup/test";
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
|
||||
import {
|
||||
FIXED_NOW,
|
||||
done,
|
||||
end,
|
||||
feed,
|
||||
installLiveCoachStream,
|
||||
posts,
|
||||
serveConversation,
|
||||
tokens,
|
||||
applyTheme,
|
||||
type StubConversation,
|
||||
} from "./setup/coach-live-stream";
|
||||
|
||||
/**
|
||||
* What the Coach keeps, in the same quiet language as everything else in
|
||||
* the thread, and the thinking depth in its quick settings.
|
||||
*
|
||||
* 1. A fact it saved: one meta line under the answer, "Remembered: …",
|
||||
* with Undo, which forgets it.
|
||||
* 2. A fact it proposes (health): no line; "Yes, remember it" / "No" as
|
||||
* reply pills, a tap answers with `memoryDecision`.
|
||||
* 3. A plan it proposes: "Take on this plan" / "Not now", with
|
||||
* `planDecision`. No plan card in the thread.
|
||||
* 4. The memory list in Settings → Coach: an entry is edited in place.
|
||||
* 5. The thinking depth: levels above the operator's cap are disabled and
|
||||
* say so; with reasoning switched off the field is locked with one
|
||||
* sentence.
|
||||
*/
|
||||
|
||||
const CONVERSATION_ID = "coach-memory-e2e";
|
||||
|
||||
function fulfilJson(route: Route, data: unknown, status = 200) {
|
||||
return route.fulfill({
|
||||
status,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ data, error: null }),
|
||||
});
|
||||
}
|
||||
|
||||
interface Fact {
|
||||
id: string;
|
||||
category: string;
|
||||
text: string;
|
||||
confidence: number;
|
||||
source: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
async function serveFacts(page: Page, facts: Fact[]) {
|
||||
const calls: Array<{ method: string; path: string; body: unknown }> = [];
|
||||
await page.route(
|
||||
/\/api\/insights\/coach\/facts(?:\/[^?]+)?(?:\?.*)?$/,
|
||||
async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
const method = request.method();
|
||||
calls.push({
|
||||
method,
|
||||
path: url.pathname,
|
||||
body: method === "GET" ? null : request.postDataJSON(),
|
||||
});
|
||||
const id = url.pathname.split("/").pop() ?? "";
|
||||
if (method === "DELETE") {
|
||||
const at = facts.findIndex((f) => f.id === id);
|
||||
if (at !== -1) facts.splice(at, 1);
|
||||
return fulfilJson(route, { deleted: true });
|
||||
}
|
||||
if (method === "PATCH") {
|
||||
const fact = facts.find((f) => f.id === id);
|
||||
const next = (request.postDataJSON() as { fact: string }).fact;
|
||||
if (fact) fact.text = next;
|
||||
return fulfilJson(route, { fact });
|
||||
}
|
||||
return fulfilJson(route, { facts });
|
||||
},
|
||||
);
|
||||
return calls;
|
||||
}
|
||||
|
||||
function stub(): StubConversation {
|
||||
return {
|
||||
id: CONVERSATION_ID,
|
||||
title: "Goals",
|
||||
messages: [],
|
||||
results: {},
|
||||
trails: {},
|
||||
};
|
||||
}
|
||||
|
||||
async function send(page: Page, text: string) {
|
||||
await page.locator('[data-slot="coach-input-textarea"]').fill(text);
|
||||
await page.locator('[data-slot="coach-input-send"]').click();
|
||||
}
|
||||
|
||||
async function answer(
|
||||
page: Page,
|
||||
conversation: StubConversation,
|
||||
turn: {
|
||||
userId: string;
|
||||
userText: string;
|
||||
id: string;
|
||||
text: string;
|
||||
metricSource: Record<string, unknown>;
|
||||
frames: unknown[];
|
||||
providerType?: string;
|
||||
},
|
||||
) {
|
||||
conversation.messages.push(
|
||||
{ id: turn.userId, role: "user", content: turn.userText },
|
||||
{
|
||||
id: turn.id,
|
||||
role: "assistant",
|
||||
content: turn.text,
|
||||
metricSource: turn.metricSource,
|
||||
providerType: turn.providerType,
|
||||
},
|
||||
);
|
||||
await feed(page, [
|
||||
...tokens(turn.text),
|
||||
{ type: "provenance", metricSource: turn.metricSource },
|
||||
...turn.frames,
|
||||
done(CONVERSATION_ID, turn.id),
|
||||
]);
|
||||
await end(page);
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-slot="coach-bubble-assistant"]')
|
||||
.filter({ hasText: turn.text }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe("Coach memory and plans", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name !== "chromium-desktop",
|
||||
"viewport-independent; desktop run suffices",
|
||||
);
|
||||
await page.clock.setFixedTime(FIXED_NOW);
|
||||
await applyTheme(page, "light");
|
||||
});
|
||||
|
||||
test("a saved fact is one quiet line, and Undo forgets it", async ({
|
||||
page,
|
||||
}) => {
|
||||
await serveAiBlock(page, aiBlockAvailable());
|
||||
const facts: Fact[] = [
|
||||
{
|
||||
id: "fact-1",
|
||||
category: "goal",
|
||||
text: "You want to reach 75 kg by December",
|
||||
confidence: 1,
|
||||
source: "coach",
|
||||
createdAt: "2026-10-06T09:00:00.000Z",
|
||||
},
|
||||
];
|
||||
const calls = await serveFacts(page, facts);
|
||||
const conversation = stub();
|
||||
await serveConversation(page, conversation);
|
||||
await installLiveCoachStream(page);
|
||||
await page.goto("/coach", { waitUntil: "domcontentloaded" });
|
||||
|
||||
await send(page, "I want to reach 75 kg by December.");
|
||||
await answer(page, conversation, {
|
||||
userId: "coach-memory-e2e-u1",
|
||||
userText: "I want to reach 75 kg by December.",
|
||||
id: "coach-memory-e2e-a1",
|
||||
text: "That is about 3 kg from where you are now, a steady pace over ten weeks.",
|
||||
metricSource: {
|
||||
windows: [],
|
||||
metrics: ["weight"],
|
||||
memoryNote: { proposal: false, factId: "fact-1", category: "goal" },
|
||||
},
|
||||
frames: [
|
||||
{
|
||||
type: "memoryNote",
|
||||
note: {
|
||||
proposal: false,
|
||||
factId: "fact-1",
|
||||
category: "goal",
|
||||
fact: "You want to reach 75 kg by December",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const note = page.locator('[data-slot="coach-memory-note"]');
|
||||
await expect(note).toHaveText(
|
||||
/Remembered: You want to reach 75 kg by December\.\s*Undo/,
|
||||
);
|
||||
// No card, no accent: a meta line.
|
||||
await expect(note).toHaveClass(/text-muted-foreground/);
|
||||
await note.locator('[data-slot="coach-memory-note-undo"]').click();
|
||||
await expect(note).toHaveCount(0);
|
||||
expect(
|
||||
calls.some((c) => c.method === "DELETE" && c.path.endsWith("/fact-1")),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("a proposed fact and a proposed plan are answered with reply pills", async ({
|
||||
page,
|
||||
}) => {
|
||||
await serveAiBlock(page, aiBlockAvailable());
|
||||
await serveFacts(page, []);
|
||||
const conversation = stub();
|
||||
await serveConversation(page, conversation);
|
||||
await installLiveCoachStream(page);
|
||||
await page.goto("/coach", { waitUntil: "domcontentloaded" });
|
||||
|
||||
// A health fact is proposed, never saved by itself.
|
||||
await send(page, "I started taking ramipril in May.");
|
||||
await answer(page, conversation, {
|
||||
userId: "coach-memory-e2e-u1",
|
||||
userText: "I started taking ramipril in May.",
|
||||
id: "coach-memory-e2e-a1",
|
||||
text: "Your readings since May sit a little lower. Should I remember that you take ramipril since May?",
|
||||
metricSource: {
|
||||
windows: [],
|
||||
metrics: ["bp"],
|
||||
memoryNote: {
|
||||
proposal: true,
|
||||
proposalId: "p1",
|
||||
category: "medication",
|
||||
},
|
||||
},
|
||||
frames: [
|
||||
{
|
||||
type: "memoryNote",
|
||||
note: {
|
||||
proposal: true,
|
||||
proposalId: "p1",
|
||||
category: "medication",
|
||||
fact: "Takes ramipril since May",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
await expect(page.locator('[data-slot="coach-memory-note"]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
const pills = page.locator('[data-slot="coach-suggested-replies"]');
|
||||
await expect(pills.locator("[data-reply-id]")).toHaveText([
|
||||
"Yes, remember it",
|
||||
"No",
|
||||
]);
|
||||
await pills.locator('[data-reply-id="memory-accept"]').click();
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-slot="coach-bubble-user"]')
|
||||
.filter({ hasText: "Yes, remember it" }),
|
||||
).toBeVisible();
|
||||
expect((await posts(page))[1]).toMatchObject({
|
||||
message: "Yes, remember it",
|
||||
memoryDecision: {
|
||||
messageId: "coach-memory-e2e-a1",
|
||||
proposalId: "p1",
|
||||
accept: true,
|
||||
},
|
||||
});
|
||||
await answer(page, conversation, {
|
||||
userId: "coach-memory-e2e-u2",
|
||||
userText: "Yes, remember it",
|
||||
id: "coach-memory-e2e-a2",
|
||||
text: "Got it, I'll remember.",
|
||||
providerType: "decision",
|
||||
metricSource: { windows: [], metrics: [] },
|
||||
frames: [],
|
||||
});
|
||||
await expect(pills).toHaveCount(0);
|
||||
|
||||
// A plan is one sentence in the answer, then two pills. No card.
|
||||
await send(page, "How do I stop snacking late?");
|
||||
await answer(page, conversation, {
|
||||
userId: "coach-memory-e2e-u3",
|
||||
userText: "How do I stop snacking late?",
|
||||
id: "coach-memory-e2e-a3",
|
||||
text: "When you finish dinner, then brush your teeth right away. I'll check in with you in 14 days.",
|
||||
metricSource: {
|
||||
windows: [],
|
||||
metrics: [],
|
||||
planProposal: { planId: "plan-1", metric: "WEIGHT", reviewInDays: 14 },
|
||||
},
|
||||
frames: [
|
||||
{
|
||||
type: "planProposal",
|
||||
proposal: {
|
||||
planId: "plan-1",
|
||||
metric: "WEIGHT",
|
||||
reviewInDays: 14,
|
||||
ifCue: "When you finish dinner",
|
||||
thenAction: "brush your teeth right away",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
await expect(
|
||||
page.locator('[data-slot="coach-plan-proposal-card"]'),
|
||||
).toHaveCount(0);
|
||||
await expect(pills.locator("[data-reply-id]")).toHaveText([
|
||||
"Take on this plan",
|
||||
"Not now",
|
||||
]);
|
||||
await pills.locator('[data-reply-id="plan-decline"]').click();
|
||||
expect((await posts(page))[3]).toMatchObject({
|
||||
message: "Not now",
|
||||
planDecision: {
|
||||
messageId: "coach-memory-e2e-a3",
|
||||
planId: "plan-1",
|
||||
accept: false,
|
||||
},
|
||||
});
|
||||
await end(page);
|
||||
});
|
||||
|
||||
test("an entry in the memory list is edited in place", async ({ page }) => {
|
||||
await serveAiBlock(page, aiBlockAvailable());
|
||||
const calls = await serveFacts(page, [
|
||||
{
|
||||
id: "fact-1",
|
||||
category: "goal",
|
||||
text: "Wants to reach 80 kg by autumn",
|
||||
confidence: 1,
|
||||
source: "user",
|
||||
createdAt: "2026-10-01T09:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "fact-2",
|
||||
category: "preference",
|
||||
text: "Prefers morning workouts",
|
||||
confidence: 0.8,
|
||||
source: "coach",
|
||||
createdAt: "2026-10-02T09:00:00.000Z",
|
||||
},
|
||||
]);
|
||||
await page.goto("/settings/coach#coach-memory", {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
const card = page.locator("#coach-memory");
|
||||
const rows = card.locator('[data-testid="settings-coach-memory-fact"]');
|
||||
await expect(rows).toHaveCount(2);
|
||||
await expect(rows.nth(0)).toContainText("from you");
|
||||
await expect(rows.nth(1)).toContainText("from the Coach");
|
||||
|
||||
await rows
|
||||
.nth(0)
|
||||
.locator('[data-slot="settings-coach-memory-edit"]')
|
||||
.click();
|
||||
const input = rows
|
||||
.nth(0)
|
||||
.locator('[data-slot="settings-coach-memory-edit-input"]');
|
||||
await input.fill("Wants to reach 78 kg by December");
|
||||
await rows
|
||||
.nth(0)
|
||||
.locator('[data-slot="settings-coach-memory-edit-save"]')
|
||||
.click();
|
||||
await expect(rows.nth(0)).toContainText("Wants to reach 78 kg by December");
|
||||
expect(
|
||||
calls.find((c) => c.method === "PATCH" && c.path.endsWith("/fact-1"))
|
||||
?.body,
|
||||
).toEqual({ fact: "Wants to reach 78 kg by December" });
|
||||
});
|
||||
|
||||
for (const scenario of [
|
||||
{
|
||||
name: "capped at Medium",
|
||||
block: {
|
||||
level: "medium",
|
||||
preference: "medium",
|
||||
maxLevel: "medium",
|
||||
available: true,
|
||||
offIsReal: true,
|
||||
source: "admin_cap",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "switched off by the operator",
|
||||
block: {
|
||||
level: "off",
|
||||
preference: "medium",
|
||||
maxLevel: "off",
|
||||
available: false,
|
||||
offIsReal: true,
|
||||
source: "admin_off",
|
||||
},
|
||||
},
|
||||
]) {
|
||||
test(`thinking depth in the quick settings, ${scenario.name}`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.route("**/api/auth/me", async (route) => {
|
||||
const response = await route.fetch();
|
||||
const body = (await response.json()) as {
|
||||
data: Record<string, unknown> | null;
|
||||
};
|
||||
if (body.data) {
|
||||
body.data.ai = aiBlockAvailable();
|
||||
body.data.coachReasoning = scenario.block;
|
||||
}
|
||||
await route.fulfill({ response, json: body });
|
||||
});
|
||||
await serveFacts(page, []);
|
||||
await serveConversation(page, stub());
|
||||
await page.goto("/coach", { waitUntil: "domcontentloaded" });
|
||||
await page.locator('[data-slot="coach-settings"]').first().click();
|
||||
const select = page.locator('[data-slot="coach-reasoning-select"]');
|
||||
await expect(select).toBeVisible();
|
||||
await expect(select.locator("option")).toHaveText([
|
||||
"Off",
|
||||
"Low",
|
||||
"Medium",
|
||||
scenario.block.available ? "High (limited by the admin)" : "High",
|
||||
]);
|
||||
const field = page.locator('[data-slot="coach-reasoning-field"]');
|
||||
if (scenario.block.available) {
|
||||
await expect(select).toBeEnabled();
|
||||
await expect(select.locator('option[value="high"]')).toBeDisabled();
|
||||
await expect(field).toContainText("a little more waiting");
|
||||
} else {
|
||||
await expect(select).toBeDisabled();
|
||||
await expect(field).toContainText(
|
||||
"The admin has turned deeper thinking off.",
|
||||
);
|
||||
}
|
||||
// The memory link names how much the Coach keeps.
|
||||
await expect(
|
||||
page.locator('[data-slot="coach-settings-memory-link"]'),
|
||||
).toHaveText("Memory (0)");
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -77,6 +77,8 @@ export async function resetAiOptionalAccount(): Promise<void> {
|
||||
export async function seedStoredBriefing(marker: string): Promise<void> {
|
||||
const insights = {
|
||||
dailyBriefing: { paragraph: marker, keyFindings: [] },
|
||||
// Generated now: only text written today is served as today's read.
|
||||
briefingGeneratedAt: new Date().toISOString(),
|
||||
};
|
||||
await withAccount(async (pool, userId) => {
|
||||
await pool.query(
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import type { Page, Request, Route } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* A Coach chat stream the spec drives frame by frame.
|
||||
*
|
||||
* `route.fulfill` hands the browser the whole event stream at once, so the
|
||||
* live states of a turn (the status line moving from "Thinking…" to
|
||||
* "Fetching…" to "Summarising…", an interim table before the first token)
|
||||
* would flash by unobserved. This replaces `fetch` in the page for the chat
|
||||
* POST only: the response body is a stream the spec feeds with
|
||||
* `feed(page, frames)` and closes with `end(page)`. Every other request goes
|
||||
* through untouched, so `page.route` mocks keep working.
|
||||
*
|
||||
* The POST bodies are recorded in the page (`posts(page)`).
|
||||
*/
|
||||
export async function installLiveCoachStream(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
const w = window as unknown as {
|
||||
__coachPosts: unknown[];
|
||||
__coachFeed: (frames: unknown[]) => void;
|
||||
__coachEnd: () => void;
|
||||
};
|
||||
const original = window.fetch.bind(window);
|
||||
let controller: ReadableStreamDefaultController<Uint8Array> | null = null;
|
||||
const encoder = new TextEncoder();
|
||||
w.__coachPosts = [];
|
||||
w.__coachFeed = (frames) => {
|
||||
for (const frame of frames) {
|
||||
controller?.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify(frame)}\n\n`),
|
||||
);
|
||||
}
|
||||
};
|
||||
w.__coachEnd = () => {
|
||||
controller?.close();
|
||||
controller = null;
|
||||
};
|
||||
window.fetch = async (input, init) => {
|
||||
const url =
|
||||
typeof input === "string"
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.href
|
||||
: input.url;
|
||||
const method = (
|
||||
init?.method ?? (input instanceof Request ? input.method : "GET")
|
||||
).toUpperCase();
|
||||
if (
|
||||
method === "POST" &&
|
||||
new URL(url, window.location.href).pathname === "/api/insights/chat"
|
||||
) {
|
||||
w.__coachPosts.push(JSON.parse(String(init?.body ?? "{}")));
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(c) {
|
||||
controller = c;
|
||||
},
|
||||
});
|
||||
init?.signal?.addEventListener("abort", () => {
|
||||
try {
|
||||
controller?.error(new DOMException("aborted", "AbortError"));
|
||||
} catch {
|
||||
// already closed
|
||||
}
|
||||
});
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
});
|
||||
}
|
||||
return original(input, init);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function feed(page: Page, frames: unknown[]): Promise<void> {
|
||||
await page.evaluate((f) => {
|
||||
(window as unknown as { __coachFeed: (x: unknown[]) => void }).__coachFeed(
|
||||
f,
|
||||
);
|
||||
}, frames);
|
||||
}
|
||||
|
||||
export async function end(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
(window as unknown as { __coachEnd: () => void }).__coachEnd();
|
||||
});
|
||||
}
|
||||
|
||||
export async function posts(
|
||||
page: Page,
|
||||
): Promise<Array<Record<string, unknown>>> {
|
||||
return page.evaluate(
|
||||
() =>
|
||||
(window as unknown as { __coachPosts: Array<Record<string, unknown>> })
|
||||
.__coachPosts,
|
||||
);
|
||||
}
|
||||
|
||||
export function tokens(text: string) {
|
||||
return text.split(/(?<= )/).map((token) => ({ type: "token", token }));
|
||||
}
|
||||
|
||||
function fulfilJson(route: Route, data: unknown) {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ data, error: null }),
|
||||
});
|
||||
}
|
||||
|
||||
export interface StoredMessage {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
metricSource?: Record<string, unknown> | null;
|
||||
providerType?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The conversation as the stubbed server holds it. The spec pushes the
|
||||
* persisted turn before it sends `done`, so the refetch after `done` finds
|
||||
* the copy that replaces the streamed reply.
|
||||
*/
|
||||
export interface StubConversation {
|
||||
id: string;
|
||||
title: string;
|
||||
messages: StoredMessage[];
|
||||
/** `…/messages/{id}/results`, by message id. */
|
||||
results: Record<string, unknown[]>;
|
||||
/** `…/messages/{id}/trail`, by message id. */
|
||||
trails: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function serveConversation(
|
||||
page: Page,
|
||||
conversation: StubConversation,
|
||||
): Promise<void> {
|
||||
const at = (index: number) =>
|
||||
new Date(Date.UTC(2026, 9, 6, 9, 0, index)).toISOString();
|
||||
const summary = () => ({
|
||||
id: conversation.id,
|
||||
title: conversation.title,
|
||||
createdAt: at(0),
|
||||
updatedAt: at(conversation.messages.length),
|
||||
messageCount: conversation.messages.length,
|
||||
fenced: false,
|
||||
attachments: [],
|
||||
documentTitle: null,
|
||||
});
|
||||
await page.route(
|
||||
/\/api\/insights\/chat(?:\/[^?]+)?(?:\?.*)?$/,
|
||||
async (route: Route, request: Request) => {
|
||||
const url = new URL(request.url());
|
||||
const base = `/api/insights/chat/${conversation.id}`;
|
||||
const message = url.pathname.match(
|
||||
/\/messages\/([^/]+)\/(results|trail)$/,
|
||||
);
|
||||
if (message) {
|
||||
const [, id, kind] = message;
|
||||
return kind === "results"
|
||||
? fulfilJson(route, { results: conversation.results[id] ?? [] })
|
||||
: fulfilJson(route, {
|
||||
trail: conversation.trails[id] ?? null,
|
||||
ai: { available: true, reason: null },
|
||||
});
|
||||
}
|
||||
if (url.pathname === base) {
|
||||
return fulfilJson(route, {
|
||||
...summary(),
|
||||
attachmentCount: 0,
|
||||
summary: null,
|
||||
messages: conversation.messages.map((m, index) => ({
|
||||
id: m.id,
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
createdAt: at(index + 1),
|
||||
metricSource: m.metricSource ?? null,
|
||||
providerType:
|
||||
m.role === "assistant" ? (m.providerType ?? "mock") : null,
|
||||
promptVersion: null,
|
||||
tokensUsed: m.role === "assistant" ? 420 : null,
|
||||
model: m.role === "assistant" ? "mock" : null,
|
||||
})),
|
||||
});
|
||||
}
|
||||
if (url.pathname === "/api/insights/chat" && request.method() === "GET") {
|
||||
return fulfilJson(route, {
|
||||
conversations: conversation.messages.length > 0 ? [summary()] : [],
|
||||
nextCursor: null,
|
||||
});
|
||||
}
|
||||
return route.fallback();
|
||||
},
|
||||
);
|
||||
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: [] }),
|
||||
);
|
||||
}
|
||||
|
||||
export function done(conversationId: string, messageId: string) {
|
||||
return {
|
||||
type: "done",
|
||||
conversationId,
|
||||
messageId,
|
||||
usage: { totalTokens: 420 },
|
||||
};
|
||||
}
|
||||
|
||||
export async function applyTheme(page: Page, theme: "light" | "dark") {
|
||||
await page.emulateMedia({ colorScheme: theme, reducedMotion: "reduce" });
|
||||
await page.addInitScript((value: string) => {
|
||||
window.localStorage.setItem("healthlog-theme", value);
|
||||
}, theme);
|
||||
}
|
||||
|
||||
/** Midday, so no "today / yesterday" boundary moves a label. */
|
||||
export const FIXED_NOW = new Date("2026-10-06T12:00:00+02:00");
|
||||
+348
-83
@@ -16,11 +16,13 @@ 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.
|
||||
* see: the facts sit beside the ring on a wide screen; on a phone the ring is
|
||||
* a compact dial at the top right beside the lead and the facts run the full
|
||||
* card width under both; 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 per breakpoint. Each state is also
|
||||
* captured as an element screenshot, in both themes, 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
|
||||
@@ -36,8 +38,17 @@ const SHOTS = join(process.cwd(), "test-results", "today-overview");
|
||||
const VIEWPORTS = [
|
||||
{ name: "desktop", width: 1440, height: 900 },
|
||||
{ name: "mobile", width: 390, height: 844 },
|
||||
{ name: "narrow", width: 360, height: 780 },
|
||||
] as const;
|
||||
|
||||
const THEMES = ["dark", "light"] as const;
|
||||
|
||||
/** The dial's edge per breakpoint: compact on a phone, md from 768 px. */
|
||||
const RING_PX = { phone: 80, wide: 168 } as const;
|
||||
|
||||
/** Below this the lead beside the ring would read as a column of words. */
|
||||
const MIN_LEAD_WIDTH = 180;
|
||||
|
||||
const BASE: DailyDigest = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
ai: DIGEST_AI_AVAILABLE,
|
||||
@@ -158,6 +169,39 @@ const STATES: Record<string, DailyDigest> = {
|
||||
},
|
||||
today: [FACTS[0], FACTS[2], FACTS[4]],
|
||||
},
|
||||
// AI on with a long lead and a long sleep value: the phone case the ring
|
||||
// used to fall under the facts for.
|
||||
"ai-on-long": {
|
||||
...BASE,
|
||||
score: {
|
||||
value: 63,
|
||||
band: "yellow",
|
||||
delta: -6,
|
||||
deltaReason: null,
|
||||
steadyWeeks: null,
|
||||
},
|
||||
lead: {
|
||||
text: "Your pulse sits well above its usual level this evening, after a shorter night than you normally get; a quieter evening would suit it.",
|
||||
source: "briefing",
|
||||
},
|
||||
signalLine: { headline: null, delta: "+34 bpm vs your 30-day average" },
|
||||
today: [
|
||||
FACTS[0],
|
||||
{
|
||||
kind: "sleep",
|
||||
label: "Last night",
|
||||
value: "5h 45m, 42m less than usual",
|
||||
href: "/insights/sleep",
|
||||
moduleKey: "sleep",
|
||||
},
|
||||
{
|
||||
kind: "vitals",
|
||||
label: "Vitals",
|
||||
value: "Pulse above your range",
|
||||
href: "/insights",
|
||||
},
|
||||
],
|
||||
},
|
||||
// A quiet day: one calm sentence and no facts beyond it.
|
||||
quiet: {
|
||||
...BASE,
|
||||
@@ -195,96 +239,163 @@ test.describe("Today overview geometry", () => {
|
||||
});
|
||||
|
||||
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);
|
||||
for (const theme of THEMES) {
|
||||
for (const [state, digest] of Object.entries(STATES)) {
|
||||
test(`${state} at ${viewport.width}px, ${theme}`, async ({ page }) => {
|
||||
// The app paints from its own stored preference, not the media
|
||||
// query, so both are set.
|
||||
await page.emulateMedia({
|
||||
colorScheme: theme,
|
||||
reducedMotion: "reduce",
|
||||
});
|
||||
await page.addInitScript((value: string) => {
|
||||
window.localStorage.setItem("healthlog-theme", value);
|
||||
}, theme);
|
||||
await page.setViewportSize(viewport);
|
||||
const hero = await openDashboard(page, digest);
|
||||
const phone = viewport.width < 768;
|
||||
|
||||
mkdirSync(SHOTS, { recursive: true });
|
||||
await hero.screenshot({
|
||||
path: join(SHOTS, `${viewport.name}-${state}.png`),
|
||||
});
|
||||
// Late banners (the key-backup nudge) insert above the hero; let
|
||||
// the page settle so the capture shows one stable layout.
|
||||
await page.waitForLoadState("networkidle");
|
||||
mkdirSync(SHOTS, { recursive: true });
|
||||
await hero.screenshot({
|
||||
path: join(SHOTS, `${viewport.name}-${theme}-${state}.png`),
|
||||
});
|
||||
// The viewport as a reader sees it, bottom navigation included.
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
await page.screenshot({
|
||||
path: join(
|
||||
SHOTS,
|
||||
`${viewport.name}-${theme}-${state}-viewport.png`,
|
||||
),
|
||||
});
|
||||
|
||||
// Nothing pushes the page sideways.
|
||||
const overflow = await page.evaluate(
|
||||
() => document.documentElement.scrollWidth - window.innerWidth,
|
||||
);
|
||||
expect(overflow).toBeLessThanOrEqual(0);
|
||||
// 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 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);
|
||||
// The ring keeps its size: a compact dial on a phone, the same
|
||||
// 168 px dial as before from md up.
|
||||
const ring = hero.locator('[data-slot="score-ring"]').first();
|
||||
const ringBox = (await ring.boundingBox())!;
|
||||
const edge = phone ? RING_PX.phone : RING_PX.wide;
|
||||
expect(Math.round(ringBox.width)).toBe(edge);
|
||||
expect(Math.round(ringBox.height)).toBe(edge);
|
||||
|
||||
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 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 ring sits at the top right, beside the lead, and the lead
|
||||
// keeps a readable width next to it.
|
||||
expect(ringBox.x).toBeGreaterThanOrEqual(leadBox.x + leadBox.width);
|
||||
expect(ringBox.y).toBeLessThan(leadBox.y + leadBox.height);
|
||||
expect(Math.abs(ringBox.y - leadBox.y)).toBeLessThanOrEqual(8);
|
||||
expect(leadBox.width).toBeGreaterThanOrEqual(MIN_LEAD_WIDTH);
|
||||
// Symmetric insets: the ring's right edge mirrors the lead's left.
|
||||
const leftInset = leadBox.x - heroBox.x;
|
||||
const rightInset =
|
||||
heroBox.x + heroBox.width - (ringBox.x + ringBox.width);
|
||||
expect(Math.abs(leftInset - rightInset)).toBeLessThanOrEqual(1);
|
||||
|
||||
// The all-clear sentence never sits under a lead or facts.
|
||||
await expect(
|
||||
hero.locator('[data-slot="today-hero-all-clear"]'),
|
||||
).toHaveCount(0);
|
||||
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(phone ? Math.min(expected, 4) : expected);
|
||||
|
||||
if (expected === 0) {
|
||||
// The all-clear sentence never sits under a lead or facts.
|
||||
await expect(
|
||||
hero.locator('[data-slot="today-hero-today"]'),
|
||||
hero.locator('[data-slot="today-hero-all-clear"]'),
|
||||
).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,
|
||||
);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
// 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);
|
||||
});
|
||||
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 (!phone) {
|
||||
// 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 {
|
||||
// Under the dial on a phone, across the full card width.
|
||||
expect(blockBox.y).toBeGreaterThanOrEqual(
|
||||
ringBox.y + ringBox.height - 1,
|
||||
);
|
||||
expect(
|
||||
Math.abs(
|
||||
blockBox.x + blockBox.width - (ringBox.x + ringBox.width),
|
||||
),
|
||||
).toBeLessThanOrEqual(1);
|
||||
// Each tappable fact keeps the 44 px floor, label over value,
|
||||
// both on the reading edge.
|
||||
const rows = await facts.evaluateAll((items) =>
|
||||
items
|
||||
.filter((el) => (el as HTMLElement).offsetParent !== null)
|
||||
.map((el) => {
|
||||
const link = el.querySelector("a")!;
|
||||
const [label, value] = Array.from(link.children).map((c) =>
|
||||
c.getBoundingClientRect(),
|
||||
);
|
||||
return {
|
||||
height: link.getBoundingClientRect().height,
|
||||
stacked: value.top >= label.bottom - 1,
|
||||
sameEdge: Math.abs(value.left - label.left) <= 1,
|
||||
};
|
||||
}),
|
||||
);
|
||||
for (const row of rows) {
|
||||
expect(row.height).toBeGreaterThanOrEqual(44);
|
||||
expect(row.stacked).toBe(true);
|
||||
expect(row.sameEdge).toBe(true);
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,3 +435,157 @@ test.describe("Today overview geometry", () => {
|
||||
expect(Math.abs(after.width - before.width)).toBeLessThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The phone in German, where the score's meta lines are longest. In the
|
||||
* dial's 80 px column "−6 vs. dein Mittel" and the basis sentence broke into
|
||||
* three to five rows each; a short lead then left a gap above the facts as
|
||||
* tall as that column. The lines now run on their own row under the dial.
|
||||
*/
|
||||
const GERMAN_SCORE: DailyDigest["score"] = {
|
||||
value: 63,
|
||||
band: "yellow",
|
||||
delta: -6,
|
||||
deltaReason: null,
|
||||
steadyWeeks: null,
|
||||
scoreBasis: {
|
||||
domains: 2,
|
||||
recommended: 3,
|
||||
tier: "partial",
|
||||
physiological: true,
|
||||
},
|
||||
};
|
||||
|
||||
const GERMAN_FACTS: DailyDigest["today"] = [
|
||||
{
|
||||
kind: "medications",
|
||||
label: "Medikamente",
|
||||
value: "1 von 2 genommen",
|
||||
href: "/medications",
|
||||
moduleKey: "medications",
|
||||
},
|
||||
{
|
||||
kind: "sleep",
|
||||
label: "Letzte Nacht",
|
||||
value: "5 Std. 45 Min., 42 Min. weniger als sonst",
|
||||
href: "/insights/sleep",
|
||||
moduleKey: "sleep",
|
||||
},
|
||||
{
|
||||
kind: "vitals",
|
||||
label: "Vitalwerte",
|
||||
value: "Puls über deinem Bereich",
|
||||
href: "/insights",
|
||||
},
|
||||
];
|
||||
|
||||
const GERMAN_STATES: Record<string, DailyDigest> = {
|
||||
"de-short": {
|
||||
...BASE,
|
||||
score: GERMAN_SCORE,
|
||||
lead: { text: "Dein Puls liegt heute erhöht.", source: "signal" },
|
||||
today: GERMAN_FACTS,
|
||||
},
|
||||
"de-long": {
|
||||
...BASE,
|
||||
score: GERMAN_SCORE,
|
||||
lead: {
|
||||
text: "Dein Puls liegt heute Abend deutlich über seinem üblichen Niveau, nach einer kürzeren Nacht als sonst; ein ruhiger Abend täte ihm gut.",
|
||||
source: "briefing",
|
||||
},
|
||||
signalLine: { headline: null, delta: "+34 bpm vs. dein 30-Tage-Mittel" },
|
||||
today: GERMAN_FACTS,
|
||||
},
|
||||
};
|
||||
|
||||
test.describe("Today overview on a German phone", () => {
|
||||
test.beforeEach(async ({ context }, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name !== "chromium-desktop",
|
||||
"viewport-driven spec; desktop project only",
|
||||
);
|
||||
const baseURL = testInfo.project.use.baseURL ?? "http://localhost:3000";
|
||||
await context.addCookies([
|
||||
{ name: "healthlog-locale", value: "de", url: baseURL },
|
||||
]);
|
||||
});
|
||||
|
||||
for (const viewport of VIEWPORTS.filter((v) => v.width < 768)) {
|
||||
for (const [state, digest] of Object.entries(GERMAN_STATES)) {
|
||||
test(`${state} at ${viewport.width}px`, async ({ page }) => {
|
||||
await page.emulateMedia({
|
||||
colorScheme: "dark",
|
||||
reducedMotion: "reduce",
|
||||
});
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem("healthlog-theme", "dark");
|
||||
});
|
||||
await page.setViewportSize(viewport);
|
||||
const hero = await openDashboard(page, digest);
|
||||
await page.waitForLoadState("networkidle");
|
||||
mkdirSync(SHOTS, { recursive: true });
|
||||
await hero.screenshot({
|
||||
path: join(SHOTS, `${viewport.name}-dark-${state}.png`),
|
||||
});
|
||||
|
||||
const box = async (slot: string) =>
|
||||
(await hero.locator(`[data-slot="${slot}"]`).first().boundingBox())!;
|
||||
const ring = await box("score-ring");
|
||||
const lead = await box("today-hero-lead");
|
||||
const meta = await box("today-hero-score-meta");
|
||||
const block = await box("today-hero-today");
|
||||
|
||||
// The dial keeps its size and its place beside the lead.
|
||||
expect(Math.round(ring.width)).toBe(RING_PX.phone);
|
||||
expect(lead.width).toBeGreaterThanOrEqual(MIN_LEAD_WIDTH);
|
||||
|
||||
// Each meta line reads on one line, flush with the dial's edge.
|
||||
const lines = await hero
|
||||
.locator('[data-slot="today-hero-score-meta"] > span')
|
||||
.evaluateAll((spans) =>
|
||||
spans.map((span) => {
|
||||
const rect = span.getBoundingClientRect();
|
||||
const lineHeight = parseFloat(getComputedStyle(span).lineHeight);
|
||||
return {
|
||||
rows: Math.round(rect.height / lineHeight),
|
||||
right: rect.right,
|
||||
};
|
||||
}),
|
||||
);
|
||||
expect(lines.length).toBe(2);
|
||||
for (const line of lines) {
|
||||
expect(line.rows).toBe(1);
|
||||
expect(
|
||||
Math.abs(line.right - (ring.x + ring.width)),
|
||||
).toBeLessThanOrEqual(1);
|
||||
}
|
||||
|
||||
// The meta row follows the read row at once: right under the dial
|
||||
// beside a short lead, right under a long lead's last line.
|
||||
// The lead's cell ends with its signal line when there is one.
|
||||
const signalLine = hero.locator('[data-slot="today-hero-signal"]');
|
||||
const signal =
|
||||
(await signalLine.count()) > 0
|
||||
? await signalLine.boundingBox()
|
||||
: null;
|
||||
const leadBottom = signal
|
||||
? signal.y + signal.height
|
||||
: lead.y + lead.height;
|
||||
const readBottom = Math.max(ring.y + ring.height, leadBottom);
|
||||
expect(meta.y - readBottom).toBeGreaterThanOrEqual(0);
|
||||
expect(meta.y - readBottom).toBeLessThanOrEqual(6);
|
||||
|
||||
// No gap above the facts beyond one section step: they start one
|
||||
// row gap (16 px) under whatever ends lowest, the lead or the meta.
|
||||
const above = Math.max(leadBottom, meta.y + meta.height);
|
||||
expect(block.y - above).toBeGreaterThanOrEqual(15);
|
||||
expect(block.y - above).toBeLessThanOrEqual(17);
|
||||
|
||||
const overflow = await page.evaluate(
|
||||
() => document.documentElement.scrollWidth - window.innerWidth,
|
||||
);
|
||||
expect(overflow).toBeLessThanOrEqual(0);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
+109
-15
@@ -407,6 +407,7 @@
|
||||
"mood": "Stimmung",
|
||||
"coach": "Coach",
|
||||
"skipToContent": "Zum Inhalt springen",
|
||||
"breadcrumb": "Du bist hier",
|
||||
"sidebar": "Sidebar",
|
||||
"mainNavigation": "Hauptnavigation",
|
||||
"mobileNavigation": "Mobile Navigation",
|
||||
@@ -2796,6 +2797,7 @@
|
||||
"refusalInjection": "In deiner Nachricht stehen Anweisungen, die meine Vorgaben überschreiben sollen. Ich bin der HealthLog-Coach und fasse ausschließlich deine eigenen Gesundheitsdaten zusammen — formuliere deine Frage bitte in diesem Rahmen neu.",
|
||||
"errorBudget": "Du hast das heutige Coach-Limit erreicht. Das Budget setzt sich um Mitternacht (UTC) zurück.",
|
||||
"errorProvider": "Der Coach konnte gerade keinen Auswertungs-Anbieter erreichen. Bitte versuche es in einem Moment erneut.",
|
||||
"errorDemo": "In der Demo antwortet der Coach nicht.",
|
||||
"errorNoProvider": "Der Coach liest deine erfassten Gesundheitsdaten und beantwortet Fragen dazu (keine medizinische Beratung). Dafür braucht HealthLog einen KI-Anbieter — verbinde deinen eigenen Schlüssel oder ein lokales Modell.",
|
||||
"errorNoProviderAction": "KI-Anbieter verbinden",
|
||||
"errorNetwork": "Keine Internetverbindung — versuche es erneut, sobald du online bist.",
|
||||
@@ -2835,9 +2837,8 @@
|
||||
},
|
||||
"rememberMessage": {
|
||||
"action": "Merken",
|
||||
"done": "Gemerkt — in deiner Selbstauskunft gespeichert",
|
||||
"duplicate": "Schon in deiner Selbstauskunft enthalten",
|
||||
"failed": "Merken fehlgeschlagen. Bitte erneut versuchen."
|
||||
"failed": "Merken fehlgeschlagen. Bitte erneut versuchen.",
|
||||
"alreadyKept": "Das weiß der Coach schon."
|
||||
},
|
||||
"guided": {
|
||||
"offerTitle": "Der Coach hat {count} Rückfragen an dich",
|
||||
@@ -3018,7 +3019,6 @@
|
||||
"hideConversations": "Unterhaltungen ausblenden",
|
||||
"heroGreeting": "Frage mich etwas zu deinen Daten",
|
||||
"heroSubline": "Frag nach deinen Verläufen, Medikamenten oder Messwerten.",
|
||||
"thinkingElapsed": "{seconds}s",
|
||||
"thinkingDuration": "Nachgedacht für {seconds}s",
|
||||
"thinkingExpandAria": "Zeigen, was der Coach berücksichtigt hat",
|
||||
"thinkingDetail": "Beantwortet aus deinem Gesundheits-Snapshot.",
|
||||
@@ -3029,6 +3029,18 @@
|
||||
"baselineWithin": "Dein üblicher Bereich liegt bei {low}–{high} {unit}; heute liegt {value} darin.",
|
||||
"baselineAbove": "Dein üblicher Bereich liegt bei {low}–{high} {unit}; heute liegt {value} darüber.",
|
||||
"baselineBelow": "Dein üblicher Bereich liegt bei {low}–{high} {unit}; heute liegt {value} darunter.",
|
||||
"sameHoursWithin": "Bis zu dieser Tageszeit liegst du sonst bei {low}–{high} {unit}; heute bisher {value}, also im Bereich.",
|
||||
"sameHoursAbove": "Bis zu dieser Tageszeit liegst du sonst bei {low}–{high} {unit}; heute bisher {value}, also darüber.",
|
||||
"sameHoursBelow": "Bis zu dieser Tageszeit liegst du sonst bei {low}–{high} {unit}; heute bisher {value}, also darunter.",
|
||||
"steadyWithin": "Deine Werte liegen meist bei {low} {unit}; heute passt {value} dazu.",
|
||||
"steadyAbove": "Deine Werte liegen meist bei {low} {unit}; heute liegt {value} darüber.",
|
||||
"steadyBelow": "Deine Werte liegen meist bei {low} {unit}; heute liegt {value} darunter.",
|
||||
"latestWithin": "Dein üblicher Bereich liegt bei {low}–{high} {unit}; dein letzter Wert vom {date} lag mit {value} darin.",
|
||||
"latestAbove": "Dein üblicher Bereich liegt bei {low}–{high} {unit}; dein letzter Wert vom {date} lag mit {value} darüber.",
|
||||
"latestBelow": "Dein üblicher Bereich liegt bei {low}–{high} {unit}; dein letzter Wert vom {date} lag mit {value} darunter.",
|
||||
"steadyLatestWithin": "Deine Werte liegen meist bei {low} {unit}; dein letzter Wert vom {date} lag mit {value} genau dort.",
|
||||
"steadyLatestAbove": "Deine Werte liegen meist bei {low} {unit}; dein letzter Wert vom {date} lag mit {value} darüber.",
|
||||
"steadyLatestBelow": "Deine Werte liegen meist bei {low} {unit}; dein letzter Wert vom {date} lag mit {value} darunter.",
|
||||
"insufficient": "Lerne noch deinen üblichen Bereich — protokolliere noch ein paar Tage weiter.",
|
||||
"driver": "{note}"
|
||||
},
|
||||
@@ -3140,12 +3152,77 @@
|
||||
"rememberDone": "Gemerkt",
|
||||
"timeYesterday": "gestern {time}",
|
||||
"timeEarlier": "{date}, {time}",
|
||||
"areasDoneOne": "1 Bereich angesehen",
|
||||
"areasDoneFew": "{count} Bereiche angesehen",
|
||||
"areasDoneOther": "{count} Bereiche angesehen",
|
||||
"info": "Details zur Antwort",
|
||||
"infoModel": "Modell: {model}"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"thinking": "Denkt nach…",
|
||||
"fetching": "Holt {domain}, {window}…",
|
||||
"digestOne": "Fasst {count} Wert zusammen…",
|
||||
"digestFew": "Fasst {count} Werte zusammen…",
|
||||
"digestOther": "Fasst {count} Werte zusammen…",
|
||||
"digestDoneOne": "{count} Wert aus {areas}",
|
||||
"digestDoneFew": "{count} Werte aus {areas}",
|
||||
"digestDoneOther": "{count} Werte aus {areas}",
|
||||
"areasOne": "{count} Bereich",
|
||||
"areasFew": "{count} Bereichen",
|
||||
"areasOther": "{count} Bereichen",
|
||||
"memoryOne": "Erinnert sich an {count} Eintrag",
|
||||
"memoryFew": "Erinnert sich an {count} Einträge",
|
||||
"memoryOther": "Erinnert sich an {count} Einträge",
|
||||
"remember": "Merkt sich etwas…",
|
||||
"plan": "Schlägt einen Plan vor…",
|
||||
"asking": "Fragt nach…",
|
||||
"answer": "Schreibt die Antwort…",
|
||||
"stop": {
|
||||
"budget": "Budget erreicht, antwortet mit dem, was da ist",
|
||||
"time": "Zeit erreicht, antwortet mit dem, was da ist",
|
||||
"cap": "Viele Schritte, fasst jetzt zusammen",
|
||||
"noProgress": "Keine neuen Daten mehr, fasst zusammen"
|
||||
},
|
||||
"thoughtProcessOne": "Denkprozess · {count} Schritt",
|
||||
"thoughtProcessFew": "Denkprozess · {count} Schritte",
|
||||
"thoughtProcessOther": "Denkprozess · {count} Schritte",
|
||||
"awaitingAnswer": "Antwort nötig"
|
||||
},
|
||||
"memory": {
|
||||
"saved": "Gemerkt: {fact}",
|
||||
"undo": "Rückgängig",
|
||||
"accept": "Ja, merken",
|
||||
"decline": "Nein",
|
||||
"confirmed": "Gemerkt.",
|
||||
"declined": "Alles klar, dann nicht."
|
||||
},
|
||||
"plan": {
|
||||
"accept": "Plan übernehmen",
|
||||
"decline": "Nicht jetzt",
|
||||
"confirmed": "Ist notiert. Ich schaue in {days} Tagen mit dir drauf.",
|
||||
"declined": "Alles klar, dann nicht jetzt."
|
||||
},
|
||||
"assumption": {
|
||||
"line": "Angenommen: {value}",
|
||||
"change": "Ändern"
|
||||
},
|
||||
"suggestedReplies": {
|
||||
"groupLabel": "Antwortvorschläge"
|
||||
},
|
||||
"chart": {
|
||||
"compare": {
|
||||
"periods": "{a} gegen {b}"
|
||||
}
|
||||
},
|
||||
"reasoning": {
|
||||
"label": "Denktiefe",
|
||||
"hint": "Gründlicher denken heißt genauere Antworten und etwas mehr Wartezeit.",
|
||||
"off": "Aus",
|
||||
"minimal": "Minimal",
|
||||
"low": "Niedrig",
|
||||
"medium": "Mittel",
|
||||
"high": "Hoch",
|
||||
"capped": "(vom Admin begrenzt)",
|
||||
"disabled": "Der Admin hat gründliches Denken abgeschaltet."
|
||||
},
|
||||
"memoryLink": "Gedächtnis ({count})"
|
||||
},
|
||||
"relativeJustNow": "gerade eben",
|
||||
"relativeMinutesAgoOne": "vor {count} Minute",
|
||||
@@ -5991,7 +6068,6 @@
|
||||
"categoryGoal": "Ziele",
|
||||
"categoryConstraint": "Einschränkungen",
|
||||
"categoryContext": "Kontext",
|
||||
"forget": "Vergessen",
|
||||
"forgetAria": "Diese Angabe vergessen",
|
||||
"forgetTitle": "Diese Notiz vergessen?",
|
||||
"forgetBody": "Der Coach nutzt sie ab der nächsten Nachricht nicht mehr. Eine vergessene Notiz lässt sich nicht wiederherstellen.",
|
||||
@@ -6007,7 +6083,8 @@
|
||||
"forgotError": "Konnte diese Angabe nicht vergessen. Bitte erneut versuchen.",
|
||||
"clearedToast": "{count} gespeicherte Angaben vergessen.",
|
||||
"clearError": "Konnte die gespeicherten Angaben nicht löschen. Bitte erneut versuchen.",
|
||||
"detail": "Er nutzt sie, damit seine Antworten relevant bleiben. Entferne alles, was er besser vergessen sollte."
|
||||
"detail": "Er nutzt sie, damit seine Antworten relevant bleiben. Entferne alles, was er besser vergessen sollte.",
|
||||
"categoryMedication": "Medikamente"
|
||||
},
|
||||
"coachReminders": {
|
||||
"title": "Deine Erinnerungen",
|
||||
@@ -6123,7 +6200,8 @@
|
||||
"low": "Niedrig",
|
||||
"medium": "Mittel",
|
||||
"high": "Hoch"
|
||||
}
|
||||
},
|
||||
"coachNote": "Im Coach gilt die Denktiefe aus den Coach-Einstellungen."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -6662,6 +6740,13 @@
|
||||
"savedOn": "Coach ist aktiviert.",
|
||||
"savedOff": "Coach ist ausgeblendet.",
|
||||
"saveError": "Speichern fehlgeschlagen. Bitte erneut versuchen."
|
||||
},
|
||||
"memory": {
|
||||
"edit": "Bearbeiten",
|
||||
"source": {
|
||||
"user": "von dir",
|
||||
"coach": "vom Coach"
|
||||
}
|
||||
}
|
||||
},
|
||||
"integrationRedirectGuide": {
|
||||
@@ -7157,6 +7242,11 @@
|
||||
"documentAi": {
|
||||
"title": "Dokumente lesen",
|
||||
"description": "KI-Lesen gespeicherter Dokumente, Scans von Laborbefunden und das Erkennen von Medikamenten aus Text. Der größte Anteil der Daten, die den Server verlassen."
|
||||
},
|
||||
"reasoning": {
|
||||
"enabled": "Gründliches Denken erlauben",
|
||||
"max": "Höchste Denktiefe",
|
||||
"description": "Modelle denken vor der Antwort nach: im Coach, im Tagesbriefing und in den Zusammenfassungen. Genauer, braucht mehr Tokens."
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
@@ -9780,9 +9870,6 @@
|
||||
"showResult": "Öffne eine frühere Tabelle",
|
||||
"reuse": "Nutze die Tabelle von vorhin",
|
||||
"headerActive": "{label}…",
|
||||
"headerDoneOne": "1 Quelle angesehen",
|
||||
"headerDoneFew": "{count} Quellen angesehen",
|
||||
"headerDoneOther": "{count} Quellen angesehen",
|
||||
"listLabel": "Was der Coach angesehen hat",
|
||||
"toggleShow": "Anzeigen, was der Coach angesehen hat",
|
||||
"toggleHide": "Ausblenden, was der Coach angesehen hat",
|
||||
@@ -9932,7 +10019,8 @@
|
||||
"relatedMetric": "Siehe auch: {metric}",
|
||||
"continue": "Weitersuchen",
|
||||
"settingLabel": "Anschlussfragen vorschlagen",
|
||||
"settingHint": "Zeigt unter der letzten Antwort des Coaches bis zu drei Vorschläge für die nächste Frage."
|
||||
"settingHint": "Zeigt unter der letzten Antwort des Coaches bis zu drei Vorschläge für die nächste Frage.",
|
||||
"changeAssumption": "Stattdessen: {value}"
|
||||
},
|
||||
"clarify": {
|
||||
"cardLabel": "Der Coach hat eine Rückfrage",
|
||||
@@ -9945,6 +10033,12 @@
|
||||
"last90days": "Letzte 90 Tage",
|
||||
"lastYear": "Letzte 12 Monate",
|
||||
"allTime": "Gesamter Zeitraum"
|
||||
},
|
||||
"comparison": {
|
||||
"baseline90d": "Mit meinen üblichen 90 Tagen vergleichen"
|
||||
},
|
||||
"anchor": {
|
||||
"illness": "Seit der Erkrankung vom {date}"
|
||||
}
|
||||
},
|
||||
"reuse": {
|
||||
|
||||
+109
-15
@@ -407,6 +407,7 @@
|
||||
"mood": "Mood",
|
||||
"coach": "Coach",
|
||||
"skipToContent": "Skip to content",
|
||||
"breadcrumb": "You are here",
|
||||
"sidebar": "Sidebar",
|
||||
"mainNavigation": "Main navigation",
|
||||
"mobileNavigation": "Mobile navigation",
|
||||
@@ -2796,6 +2797,7 @@
|
||||
"refusalInjection": "I noticed wording that tries to override my instructions. I am the HealthLog Coach and only summarise your own health data — please rephrase your question in those terms.",
|
||||
"errorBudget": "You've reached today's Coach usage limit. The budget refreshes at midnight UTC.",
|
||||
"errorProvider": "The Coach could not reach an Insights provider just now. Please try again in a moment.",
|
||||
"errorDemo": "The Coach doesn't answer in the demo.",
|
||||
"errorNoProvider": "The Coach reads your logged health data and answers questions about it (not medical advice). HealthLog needs an AI provider for that — connect your own key or a local model.",
|
||||
"errorNoProviderAction": "Connect an AI provider",
|
||||
"errorNetwork": "No internet connection — try again once you're back online.",
|
||||
@@ -2835,9 +2837,8 @@
|
||||
},
|
||||
"rememberMessage": {
|
||||
"action": "Remember",
|
||||
"done": "Saved to your self-context",
|
||||
"duplicate": "Already part of your self-context",
|
||||
"failed": "Could not save. Please try again."
|
||||
"failed": "Could not save. Please try again.",
|
||||
"alreadyKept": "The Coach already remembers this."
|
||||
},
|
||||
"guided": {
|
||||
"offerTitle": "The Coach has {count} follow-up questions for you",
|
||||
@@ -3018,7 +3019,6 @@
|
||||
"hideConversations": "Hide conversations",
|
||||
"heroGreeting": "Ask me anything about your data",
|
||||
"heroSubline": "Ask about your trends, medications, or readings.",
|
||||
"thinkingElapsed": "{seconds}s",
|
||||
"thinkingDuration": "Thought for {seconds}s",
|
||||
"thinkingExpandAria": "Show what the Coach considered",
|
||||
"thinkingDetail": "Answered from your health snapshot.",
|
||||
@@ -3029,6 +3029,18 @@
|
||||
"baselineWithin": "Your usual range is {low}–{high} {unit}; today's {value} sits within it.",
|
||||
"baselineAbove": "Your usual range is {low}–{high} {unit}; today's {value} sits above it.",
|
||||
"baselineBelow": "Your usual range is {low}–{high} {unit}; today's {value} sits below it.",
|
||||
"sameHoursWithin": "By this time of day you usually sit at {low}–{high} {unit}; today so far, {value} is within that.",
|
||||
"sameHoursAbove": "By this time of day you usually sit at {low}–{high} {unit}; today so far, {value} is above that.",
|
||||
"sameHoursBelow": "By this time of day you usually sit at {low}–{high} {unit}; today so far, {value} is below that.",
|
||||
"steadyWithin": "Your readings usually sit at {low} {unit}; today's {value} matches.",
|
||||
"steadyAbove": "Your readings usually sit at {low} {unit}; today's {value} is above that.",
|
||||
"steadyBelow": "Your readings usually sit at {low} {unit}; today's {value} is below that.",
|
||||
"latestWithin": "Your usual range is {low}–{high} {unit}; your latest reading, {date}, was {value}, within it.",
|
||||
"latestAbove": "Your usual range is {low}–{high} {unit}; your latest reading, {date}, was {value}, above it.",
|
||||
"latestBelow": "Your usual range is {low}–{high} {unit}; your latest reading, {date}, was {value}, below it.",
|
||||
"steadyLatestWithin": "Your readings usually sit at {low} {unit}; your latest reading, {date}, was {value} and matches.",
|
||||
"steadyLatestAbove": "Your readings usually sit at {low} {unit}; your latest reading, {date}, was {value}, above that.",
|
||||
"steadyLatestBelow": "Your readings usually sit at {low} {unit}; your latest reading, {date}, was {value}, below that.",
|
||||
"insufficient": "Still learning your usual range — keep logging for a few more days.",
|
||||
"driver": "{note}"
|
||||
},
|
||||
@@ -3140,12 +3152,77 @@
|
||||
"rememberDone": "Remembered",
|
||||
"timeYesterday": "yesterday {time}",
|
||||
"timeEarlier": "{date}, {time}",
|
||||
"areasDoneOne": "Looked at 1 area",
|
||||
"areasDoneFew": "Looked at {count} areas",
|
||||
"areasDoneOther": "Looked at {count} areas",
|
||||
"info": "Answer details",
|
||||
"infoModel": "Model: {model}"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"thinking": "Thinking…",
|
||||
"fetching": "Fetching {domain}, {window}…",
|
||||
"digestOne": "Summarising {count} reading…",
|
||||
"digestFew": "Summarising {count} readings…",
|
||||
"digestOther": "Summarising {count} readings…",
|
||||
"digestDoneOne": "{count} reading from {areas}",
|
||||
"digestDoneFew": "{count} readings from {areas}",
|
||||
"digestDoneOther": "{count} readings from {areas}",
|
||||
"areasOne": "{count} area",
|
||||
"areasFew": "{count} areas",
|
||||
"areasOther": "{count} areas",
|
||||
"memoryOne": "Recalls {count} thing you told it",
|
||||
"memoryFew": "Recalls {count} things you told it",
|
||||
"memoryOther": "Recalls {count} things you told it",
|
||||
"remember": "Remembering something…",
|
||||
"plan": "Drafting a plan…",
|
||||
"asking": "Asking you…",
|
||||
"answer": "Writing the answer…",
|
||||
"stop": {
|
||||
"budget": "Budget reached, answering with what it has",
|
||||
"time": "Time limit reached, answering with what it has",
|
||||
"cap": "Many steps, wrapping up",
|
||||
"noProgress": "No new data, wrapping up"
|
||||
},
|
||||
"thoughtProcessOne": "Thought process · {count} step",
|
||||
"thoughtProcessFew": "Thought process · {count} steps",
|
||||
"thoughtProcessOther": "Thought process · {count} steps",
|
||||
"awaitingAnswer": "Answer needed"
|
||||
},
|
||||
"memory": {
|
||||
"saved": "Remembered: {fact}",
|
||||
"undo": "Undo",
|
||||
"accept": "Yes, remember it",
|
||||
"decline": "No",
|
||||
"confirmed": "Got it, I'll remember.",
|
||||
"declined": "Okay, I won't."
|
||||
},
|
||||
"plan": {
|
||||
"accept": "Take on this plan",
|
||||
"decline": "Not now",
|
||||
"confirmed": "Noted. I'll check in with you in {days} days.",
|
||||
"declined": "Okay, not now."
|
||||
},
|
||||
"assumption": {
|
||||
"line": "Assumed: {value}",
|
||||
"change": "Change"
|
||||
},
|
||||
"suggestedReplies": {
|
||||
"groupLabel": "Suggested replies"
|
||||
},
|
||||
"chart": {
|
||||
"compare": {
|
||||
"periods": "{a} vs {b}"
|
||||
}
|
||||
},
|
||||
"reasoning": {
|
||||
"label": "Thinking depth",
|
||||
"hint": "Deeper thinking means more precise answers and a little more waiting.",
|
||||
"off": "Off",
|
||||
"minimal": "Minimal",
|
||||
"low": "Low",
|
||||
"medium": "Medium",
|
||||
"high": "High",
|
||||
"capped": "(limited by the admin)",
|
||||
"disabled": "The admin has turned deeper thinking off."
|
||||
},
|
||||
"memoryLink": "Memory ({count})"
|
||||
},
|
||||
"relativeJustNow": "just now",
|
||||
"relativeMinutesAgoOne": "{count} minute ago",
|
||||
@@ -5991,7 +6068,6 @@
|
||||
"categoryGoal": "Goals",
|
||||
"categoryConstraint": "Constraints",
|
||||
"categoryContext": "Context",
|
||||
"forget": "Forget",
|
||||
"forgetAria": "Forget this fact",
|
||||
"forgetTitle": "Forget this fact?",
|
||||
"forgetBody": "The Coach stops using it from the next message on. There is no way to bring a forgotten fact back.",
|
||||
@@ -6007,7 +6083,8 @@
|
||||
"forgotError": "Could not forget that fact. Please try again.",
|
||||
"clearedToast": "Forgot {count} stored facts.",
|
||||
"clearError": "Could not clear the stored facts. Please try again.",
|
||||
"detail": "It uses these to keep its replies relevant. Remove anything you would rather it forgot."
|
||||
"detail": "It uses these to keep its replies relevant. Remove anything you would rather it forgot.",
|
||||
"categoryMedication": "Medications"
|
||||
},
|
||||
"coachReminders": {
|
||||
"title": "Your reminders",
|
||||
@@ -6123,7 +6200,8 @@
|
||||
"low": "Low",
|
||||
"medium": "Medium",
|
||||
"high": "High"
|
||||
}
|
||||
},
|
||||
"coachNote": "In the Coach, the thinking depth from the Coach settings applies."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -6662,6 +6740,13 @@
|
||||
"savedOn": "Coach activated.",
|
||||
"savedOff": "Coach hidden.",
|
||||
"saveError": "Could not save the change. Please try again."
|
||||
},
|
||||
"memory": {
|
||||
"edit": "Edit",
|
||||
"source": {
|
||||
"user": "from you",
|
||||
"coach": "from the Coach"
|
||||
}
|
||||
}
|
||||
},
|
||||
"integrationRedirectGuide": {
|
||||
@@ -7157,6 +7242,11 @@
|
||||
"documentAi": {
|
||||
"title": "Reading documents",
|
||||
"description": "AI reading of stored documents, lab report scans and medication extraction from text. The largest share of data leaving the server."
|
||||
},
|
||||
"reasoning": {
|
||||
"enabled": "Allow deeper thinking",
|
||||
"max": "Highest thinking depth",
|
||||
"description": "Models think before they answer: the Coach, the daily briefing and the period summaries. More precise, uses more tokens."
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
@@ -9780,9 +9870,6 @@
|
||||
"showResult": "Opening an earlier table",
|
||||
"reuse": "Using the table from before",
|
||||
"headerActive": "{label}…",
|
||||
"headerDoneOne": "Looked at 1 source",
|
||||
"headerDoneFew": "Looked at {count} sources",
|
||||
"headerDoneOther": "Looked at {count} sources",
|
||||
"listLabel": "What the Coach looked at",
|
||||
"toggleShow": "Show what the Coach looked at",
|
||||
"toggleHide": "Hide what the Coach looked at",
|
||||
@@ -9932,7 +10019,8 @@
|
||||
"relatedMetric": "See also: {metric}",
|
||||
"continue": "Keep looking",
|
||||
"settingLabel": "Suggest follow-up questions",
|
||||
"settingHint": "Show up to three follow-up suggestions under the Coach's latest answer."
|
||||
"settingHint": "Show up to three follow-up suggestions under the Coach's latest answer.",
|
||||
"changeAssumption": "Instead: {value}"
|
||||
},
|
||||
"clarify": {
|
||||
"cardLabel": "The Coach has a question",
|
||||
@@ -9945,6 +10033,12 @@
|
||||
"last90days": "Last 90 days",
|
||||
"lastYear": "Last 12 months",
|
||||
"allTime": "All time"
|
||||
},
|
||||
"comparison": {
|
||||
"baseline90d": "Compare with my usual 90 days"
|
||||
},
|
||||
"anchor": {
|
||||
"illness": "Since the illness of {date}"
|
||||
}
|
||||
},
|
||||
"reuse": {
|
||||
|
||||
+109
-15
@@ -407,6 +407,7 @@
|
||||
"mood": "Estado de ánimo",
|
||||
"coach": "Coach",
|
||||
"skipToContent": "Saltar al contenido",
|
||||
"breadcrumb": "Estás aquí",
|
||||
"sidebar": "Barra lateral",
|
||||
"mainNavigation": "Navegación principal",
|
||||
"mobileNavigation": "Navegación móvil",
|
||||
@@ -2796,6 +2797,7 @@
|
||||
"refusalInjection": "Tu mensaje contiene instrucciones que pretenden sobrescribir mis indicaciones. Soy el coach de HealthLog y solo resumo tus propios datos de salud — reformula tu pregunta dentro de ese marco.",
|
||||
"errorBudget": "Has alcanzado el límite diario del coach. El cupo se reinicia a medianoche (UTC).",
|
||||
"errorProvider": "El coach no ha podido contactar con un proveedor de análisis ahora mismo. Inténtalo en un momento.",
|
||||
"errorDemo": "En la demo, el Coach no responde.",
|
||||
"errorNoProvider": "El coach lee tus datos de salud registrados y responde preguntas sobre ellos (no es consejo médico). HealthLog necesita un proveedor de IA para ello: conecta tu propia clave o un modelo local.",
|
||||
"errorNoProviderAction": "Conectar un proveedor de IA",
|
||||
"errorNetwork": "Sin conexión a Internet — inténtalo de nuevo cuando vuelvas a estar en línea.",
|
||||
@@ -2835,9 +2837,8 @@
|
||||
},
|
||||
"rememberMessage": {
|
||||
"action": "Recordar",
|
||||
"done": "Guardado en tu información personal",
|
||||
"duplicate": "Ya está en tu información personal",
|
||||
"failed": "No se pudo guardar. Inténtalo de nuevo."
|
||||
"failed": "No se pudo guardar. Inténtalo de nuevo.",
|
||||
"alreadyKept": "El Coach ya lo recuerda."
|
||||
},
|
||||
"guided": {
|
||||
"offerTitle": "El Coach tiene {count} preguntas para ti",
|
||||
@@ -3018,7 +3019,6 @@
|
||||
"hideConversations": "Ocultar conversaciones",
|
||||
"heroGreeting": "Pregúntame lo que quieras sobre tus datos",
|
||||
"heroSubline": "Pregunta por tus tendencias, medicamentos o mediciones.",
|
||||
"thinkingElapsed": "{seconds}s",
|
||||
"thinkingDuration": "Pensó durante {seconds}s",
|
||||
"thinkingExpandAria": "Mostrar lo que el Coach tuvo en cuenta",
|
||||
"thinkingDetail": "Respondido a partir de tu resumen de salud.",
|
||||
@@ -3029,6 +3029,18 @@
|
||||
"baselineWithin": "Tu rango habitual es {low}–{high} {unit}; hoy {value} se sitúa dentro de él.",
|
||||
"baselineAbove": "Tu rango habitual es {low}–{high} {unit}; hoy {value} se sitúa por encima.",
|
||||
"baselineBelow": "Tu rango habitual es {low}–{high} {unit}; hoy {value} se sitúa por debajo.",
|
||||
"sameHoursWithin": "Hasta esta hora del día sueles estar en {low}–{high} {unit}; hoy, por ahora, {value} está dentro.",
|
||||
"sameHoursAbove": "Hasta esta hora del día sueles estar en {low}–{high} {unit}; hoy, por ahora, {value} está por encima.",
|
||||
"sameHoursBelow": "Hasta esta hora del día sueles estar en {low}–{high} {unit}; hoy, por ahora, {value} está por debajo.",
|
||||
"steadyWithin": "Tus valores suelen estar en {low} {unit}; hoy {value} coincide.",
|
||||
"steadyAbove": "Tus valores suelen estar en {low} {unit}; hoy {value} está por encima.",
|
||||
"steadyBelow": "Tus valores suelen estar en {low} {unit}; hoy {value} está por debajo.",
|
||||
"latestWithin": "Tu rango habitual es {low}–{high} {unit}; tu última medición, del {date}, fue {value}, dentro de él.",
|
||||
"latestAbove": "Tu rango habitual es {low}–{high} {unit}; tu última medición, del {date}, fue {value}, por encima.",
|
||||
"latestBelow": "Tu rango habitual es {low}–{high} {unit}; tu última medición, del {date}, fue {value}, por debajo.",
|
||||
"steadyLatestWithin": "Tus valores suelen estar en {low} {unit}; tu última medición, del {date}, fue {value} y coincide.",
|
||||
"steadyLatestAbove": "Tus valores suelen estar en {low} {unit}; tu última medición, del {date}, fue {value}, por encima.",
|
||||
"steadyLatestBelow": "Tus valores suelen estar en {low} {unit}; tu última medición, del {date}, fue {value}, por debajo.",
|
||||
"insufficient": "Todavía estoy aprendiendo tu rango habitual — sigue registrando unos días más.",
|
||||
"driver": "{note}"
|
||||
},
|
||||
@@ -3140,12 +3152,77 @@
|
||||
"rememberDone": "Guardado",
|
||||
"timeYesterday": "ayer {time}",
|
||||
"timeEarlier": "{date}, {time}",
|
||||
"areasDoneOne": "1 área consultada",
|
||||
"areasDoneFew": "{count} áreas consultadas",
|
||||
"areasDoneOther": "{count} áreas consultadas",
|
||||
"info": "Detalles de la respuesta",
|
||||
"infoModel": "Modelo: {model}"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"thinking": "Pensando…",
|
||||
"fetching": "Consultando {domain}, {window}…",
|
||||
"digestOne": "Resumiendo {count} valor…",
|
||||
"digestFew": "Resumiendo {count} valores…",
|
||||
"digestOther": "Resumiendo {count} valores…",
|
||||
"digestDoneOne": "{count} valor de {areas}",
|
||||
"digestDoneFew": "{count} valores de {areas}",
|
||||
"digestDoneOther": "{count} valores de {areas}",
|
||||
"areasOne": "{count} área",
|
||||
"areasFew": "{count} áreas",
|
||||
"areasOther": "{count} áreas",
|
||||
"memoryOne": "Recuerda {count} cosa que le contaste",
|
||||
"memoryFew": "Recuerda {count} cosas que le contaste",
|
||||
"memoryOther": "Recuerda {count} cosas que le contaste",
|
||||
"remember": "Recordando algo…",
|
||||
"plan": "Preparando un plan…",
|
||||
"asking": "Te pregunta…",
|
||||
"answer": "Escribiendo la respuesta…",
|
||||
"stop": {
|
||||
"budget": "Presupuesto alcanzado, responde con lo que tiene",
|
||||
"time": "Tiempo agotado, responde con lo que tiene",
|
||||
"cap": "Muchos pasos, ahora lo resume",
|
||||
"noProgress": "No hay datos nuevos, lo resume"
|
||||
},
|
||||
"thoughtProcessOne": "Razonamiento · {count} paso",
|
||||
"thoughtProcessFew": "Razonamiento · {count} pasos",
|
||||
"thoughtProcessOther": "Razonamiento · {count} pasos",
|
||||
"awaitingAnswer": "Se necesita tu respuesta"
|
||||
},
|
||||
"memory": {
|
||||
"saved": "Recordado: {fact}",
|
||||
"undo": "Deshacer",
|
||||
"accept": "Sí, recuérdalo",
|
||||
"decline": "No",
|
||||
"confirmed": "Entendido, lo recordaré.",
|
||||
"declined": "De acuerdo, no lo haré."
|
||||
},
|
||||
"plan": {
|
||||
"accept": "Adoptar este plan",
|
||||
"decline": "Ahora no",
|
||||
"confirmed": "Anotado. Lo revisaremos juntos dentro de {days} días.",
|
||||
"declined": "De acuerdo, ahora no."
|
||||
},
|
||||
"assumption": {
|
||||
"line": "Supuesto: {value}",
|
||||
"change": "Cambiar"
|
||||
},
|
||||
"suggestedReplies": {
|
||||
"groupLabel": "Respuestas sugeridas"
|
||||
},
|
||||
"chart": {
|
||||
"compare": {
|
||||
"periods": "{a} frente a {b}"
|
||||
}
|
||||
},
|
||||
"reasoning": {
|
||||
"label": "Profundidad de razonamiento",
|
||||
"hint": "Pensar más a fondo significa respuestas más precisas y un poco más de espera.",
|
||||
"off": "Desactivado",
|
||||
"minimal": "Mínimo",
|
||||
"low": "Bajo",
|
||||
"medium": "Medio",
|
||||
"high": "Alto",
|
||||
"capped": "(limitado por el administrador)",
|
||||
"disabled": "El administrador ha desactivado el razonamiento profundo."
|
||||
},
|
||||
"memoryLink": "Memoria ({count})"
|
||||
},
|
||||
"relativeJustNow": "ahora mismo",
|
||||
"relativeMinutesAgoOne": "hace {count} minuto",
|
||||
@@ -5991,7 +6068,6 @@
|
||||
"categoryGoal": "Objetivos",
|
||||
"categoryConstraint": "Restricciones",
|
||||
"categoryContext": "Contexto",
|
||||
"forget": "Olvidar",
|
||||
"forgetAria": "Olvidar este dato",
|
||||
"forgetTitle": "¿Olvidar este dato?",
|
||||
"forgetBody": "El Coach dejará de usarlo a partir del próximo mensaje. Un dato olvidado no se puede recuperar.",
|
||||
@@ -6007,7 +6083,8 @@
|
||||
"forgotError": "No se pudo olvidar ese dato. Inténtalo de nuevo.",
|
||||
"clearedToast": "Se olvidaron {count} datos guardados.",
|
||||
"clearError": "No se pudieron borrar los datos guardados. Inténtalo de nuevo.",
|
||||
"detail": "Los usa para que sus respuestas sigan siendo relevantes. Elimina lo que prefieras que olvide."
|
||||
"detail": "Los usa para que sus respuestas sigan siendo relevantes. Elimina lo que prefieras que olvide.",
|
||||
"categoryMedication": "Medicamentos"
|
||||
},
|
||||
"coachReminders": {
|
||||
"title": "Tus recordatorios",
|
||||
@@ -6123,7 +6200,8 @@
|
||||
"low": "Bajo",
|
||||
"medium": "Medio",
|
||||
"high": "Alto"
|
||||
}
|
||||
},
|
||||
"coachNote": "En el Coach se aplica la profundidad de pensamiento de los ajustes del Coach."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -6662,6 +6740,13 @@
|
||||
"savedOn": "Coach activado.",
|
||||
"savedOff": "Coach oculto.",
|
||||
"saveError": "No se pudo guardar el cambio. Inténtalo de nuevo."
|
||||
},
|
||||
"memory": {
|
||||
"edit": "Editar",
|
||||
"source": {
|
||||
"user": "de ti",
|
||||
"coach": "del Coach"
|
||||
}
|
||||
}
|
||||
},
|
||||
"integrationRedirectGuide": {
|
||||
@@ -7157,6 +7242,11 @@
|
||||
"documentAi": {
|
||||
"title": "Lectura de documentos",
|
||||
"description": "Lectura con IA de documentos guardados, escaneo de informes de laboratorio y extracción de medicamentos a partir de texto. La mayor parte de los datos que salen del servidor."
|
||||
},
|
||||
"reasoning": {
|
||||
"enabled": "Permitir razonamiento profundo",
|
||||
"max": "Profundidad máxima de razonamiento",
|
||||
"description": "Los modelos piensan antes de responder: el Coach, el resumen diario y los resúmenes por periodo. Más precisos, usan más tokens."
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
@@ -9780,9 +9870,6 @@
|
||||
"showResult": "Abriendo una tabla anterior",
|
||||
"reuse": "Usando la tabla de antes",
|
||||
"headerActive": "{label}…",
|
||||
"headerDoneOne": "Consultó 1 fuente",
|
||||
"headerDoneFew": "Consultó {count} fuentes",
|
||||
"headerDoneOther": "Consultó {count} fuentes",
|
||||
"listLabel": "Lo que consultó el Coach",
|
||||
"toggleShow": "Mostrar lo que consultó el Coach",
|
||||
"toggleHide": "Ocultar lo que consultó el Coach",
|
||||
@@ -9932,7 +10019,8 @@
|
||||
"relatedMetric": "Ver también: {metric}",
|
||||
"continue": "Seguir buscando",
|
||||
"settingLabel": "Sugerir preguntas de seguimiento",
|
||||
"settingHint": "Muestra hasta tres sugerencias bajo la última respuesta del Coach."
|
||||
"settingHint": "Muestra hasta tres sugerencias bajo la última respuesta del Coach.",
|
||||
"changeAssumption": "En su lugar: {value}"
|
||||
},
|
||||
"clarify": {
|
||||
"cardLabel": "El Coach tiene una pregunta",
|
||||
@@ -9945,6 +10033,12 @@
|
||||
"last90days": "Últimos 90 días",
|
||||
"lastYear": "Últimos 12 meses",
|
||||
"allTime": "Todo el periodo"
|
||||
},
|
||||
"comparison": {
|
||||
"baseline90d": "Comparar con mis 90 días habituales"
|
||||
},
|
||||
"anchor": {
|
||||
"illness": "Desde la enfermedad del {date}"
|
||||
}
|
||||
},
|
||||
"reuse": {
|
||||
|
||||
+109
-15
@@ -407,6 +407,7 @@
|
||||
"mood": "Humeur",
|
||||
"coach": "Coach",
|
||||
"skipToContent": "Aller au contenu",
|
||||
"breadcrumb": "Vous êtes ici",
|
||||
"sidebar": "Barre latérale",
|
||||
"mainNavigation": "Navigation principale",
|
||||
"mobileNavigation": "Navigation mobile",
|
||||
@@ -2796,6 +2797,7 @@
|
||||
"refusalInjection": "Ton message contient des instructions qui cherchent à remplacer mes consignes. Je suis le coach HealthLog et je ne résume que tes propres données de santé — reformule ta question dans ce cadre.",
|
||||
"errorBudget": "Tu as atteint la limite quotidienne du coach. Le quota se réinitialise à minuit (UTC).",
|
||||
"errorProvider": "Le coach n’a pas pu joindre un fournisseur d’analyse maintenant. Réessaie dans un instant.",
|
||||
"errorDemo": "Dans la démo, le Coach ne répond pas.",
|
||||
"errorNoProvider": "Le coach lit tes données de santé enregistrées et répond aux questions à leur sujet (pas un avis médical). HealthLog a besoin d'un fournisseur d'IA pour cela : connecte ta propre clé ou un modèle local.",
|
||||
"errorNoProviderAction": "Connecter un fournisseur d'IA",
|
||||
"errorNetwork": "Pas de connexion Internet — réessaie une fois reconnecté.",
|
||||
@@ -2835,9 +2837,8 @@
|
||||
},
|
||||
"rememberMessage": {
|
||||
"action": "Mémoriser",
|
||||
"done": "Enregistré dans vos informations personnelles",
|
||||
"duplicate": "Déjà présent dans vos informations personnelles",
|
||||
"failed": "Échec de l’enregistrement. Veuillez réessayer."
|
||||
"failed": "Échec de l’enregistrement. Veuillez réessayer.",
|
||||
"alreadyKept": "Le Coach s'en souvient déjà."
|
||||
},
|
||||
"guided": {
|
||||
"offerTitle": "Le Coach a {count} questions pour vous",
|
||||
@@ -3018,7 +3019,6 @@
|
||||
"hideConversations": "Masquer les conversations",
|
||||
"heroGreeting": "Pose-moi une question sur tes données",
|
||||
"heroSubline": "Pose une question sur tes tendances, médicaments ou mesures.",
|
||||
"thinkingElapsed": "{seconds}s",
|
||||
"thinkingDuration": "A réfléchi pendant {seconds}s",
|
||||
"thinkingExpandAria": "Montrer ce que le Coach a pris en compte",
|
||||
"thinkingDetail": "Réponse établie à partir de ton aperçu santé.",
|
||||
@@ -3029,6 +3029,18 @@
|
||||
"baselineWithin": "Ta plage habituelle est de {low}–{high} {unit} ; aujourd'hui, {value} s'y situe.",
|
||||
"baselineAbove": "Ta plage habituelle est de {low}–{high} {unit} ; aujourd'hui, {value} se situe au-dessus.",
|
||||
"baselineBelow": "Ta plage habituelle est de {low}–{high} {unit} ; aujourd'hui, {value} se situe en dessous.",
|
||||
"sameHoursWithin": "À ce moment de la journée, tu es habituellement à {low}–{high} {unit} ; aujourd'hui jusqu'ici, {value} s'y situe.",
|
||||
"sameHoursAbove": "À ce moment de la journée, tu es habituellement à {low}–{high} {unit} ; aujourd'hui jusqu'ici, {value} se situe au-dessus.",
|
||||
"sameHoursBelow": "À ce moment de la journée, tu es habituellement à {low}–{high} {unit} ; aujourd'hui jusqu'ici, {value} se situe en dessous.",
|
||||
"steadyWithin": "Tes valeurs sont habituellement à {low} {unit} ; aujourd'hui, {value} correspond.",
|
||||
"steadyAbove": "Tes valeurs sont habituellement à {low} {unit} ; aujourd'hui, {value} se situe au-dessus.",
|
||||
"steadyBelow": "Tes valeurs sont habituellement à {low} {unit} ; aujourd'hui, {value} se situe en dessous.",
|
||||
"latestWithin": "Ta plage habituelle est de {low}–{high} {unit} ; ta dernière mesure, du {date}, était de {value}, dans cette plage.",
|
||||
"latestAbove": "Ta plage habituelle est de {low}–{high} {unit} ; ta dernière mesure, du {date}, était de {value}, au-dessus.",
|
||||
"latestBelow": "Ta plage habituelle est de {low}–{high} {unit} ; ta dernière mesure, du {date}, était de {value}, en dessous.",
|
||||
"steadyLatestWithin": "Tes valeurs sont habituellement à {low} {unit} ; ta dernière mesure, du {date}, était de {value}, comme d'habitude.",
|
||||
"steadyLatestAbove": "Tes valeurs sont habituellement à {low} {unit} ; ta dernière mesure, du {date}, était de {value}, au-dessus.",
|
||||
"steadyLatestBelow": "Tes valeurs sont habituellement à {low} {unit} ; ta dernière mesure, du {date}, était de {value}, en dessous.",
|
||||
"insufficient": "J'apprends encore ta plage habituelle — continue à enregistrer quelques jours de plus.",
|
||||
"driver": "{note}"
|
||||
},
|
||||
@@ -3140,12 +3152,77 @@
|
||||
"rememberDone": "Mémorisé",
|
||||
"timeYesterday": "hier {time}",
|
||||
"timeEarlier": "{date}, {time}",
|
||||
"areasDoneOne": "1 domaine consulté",
|
||||
"areasDoneFew": "{count} domaines consultés",
|
||||
"areasDoneOther": "{count} domaines consultés",
|
||||
"info": "Détails de la réponse",
|
||||
"infoModel": "Modèle : {model}"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"thinking": "Réfléchit…",
|
||||
"fetching": "Récupère {domain}, {window}…",
|
||||
"digestOne": "Résume {count} mesure…",
|
||||
"digestFew": "Résume {count} mesures…",
|
||||
"digestOther": "Résume {count} mesures…",
|
||||
"digestDoneOne": "{count} mesure sur {areas}",
|
||||
"digestDoneFew": "{count} mesures sur {areas}",
|
||||
"digestDoneOther": "{count} mesures sur {areas}",
|
||||
"areasOne": "{count} domaine",
|
||||
"areasFew": "{count} domaines",
|
||||
"areasOther": "{count} domaines",
|
||||
"memoryOne": "Se souvient de {count} chose que tu lui as dite",
|
||||
"memoryFew": "Se souvient de {count} choses que tu lui as dites",
|
||||
"memoryOther": "Se souvient de {count} choses que tu lui as dites",
|
||||
"remember": "Retient quelque chose…",
|
||||
"plan": "Propose un plan…",
|
||||
"asking": "Te pose une question…",
|
||||
"answer": "Rédige la réponse…",
|
||||
"stop": {
|
||||
"budget": "Budget atteint, répond avec ce qu'il a",
|
||||
"time": "Temps écoulé, répond avec ce qu'il a",
|
||||
"cap": "Beaucoup d'étapes, conclut maintenant",
|
||||
"noProgress": "Plus de nouvelles données, conclut"
|
||||
},
|
||||
"thoughtProcessOne": "Raisonnement · {count} étape",
|
||||
"thoughtProcessFew": "Raisonnement · {count} étapes",
|
||||
"thoughtProcessOther": "Raisonnement · {count} étapes",
|
||||
"awaitingAnswer": "Réponse attendue"
|
||||
},
|
||||
"memory": {
|
||||
"saved": "Retenu : {fact}",
|
||||
"undo": "Annuler",
|
||||
"accept": "Oui, retiens-le",
|
||||
"decline": "Non",
|
||||
"confirmed": "C'est noté, je m'en souviendrai.",
|
||||
"declined": "D'accord, je ne le ferai pas."
|
||||
},
|
||||
"plan": {
|
||||
"accept": "Adopter ce plan",
|
||||
"decline": "Pas maintenant",
|
||||
"confirmed": "C'est noté. On fait le point ensemble dans {days} jours.",
|
||||
"declined": "D'accord, pas maintenant."
|
||||
},
|
||||
"assumption": {
|
||||
"line": "Hypothèse : {value}",
|
||||
"change": "Modifier"
|
||||
},
|
||||
"suggestedReplies": {
|
||||
"groupLabel": "Réponses suggérées"
|
||||
},
|
||||
"chart": {
|
||||
"compare": {
|
||||
"periods": "{a} contre {b}"
|
||||
}
|
||||
},
|
||||
"reasoning": {
|
||||
"label": "Profondeur de réflexion",
|
||||
"hint": "Réfléchir plus en profondeur donne des réponses plus précises, avec un peu plus d'attente.",
|
||||
"off": "Désactivé",
|
||||
"minimal": "Minimale",
|
||||
"low": "Faible",
|
||||
"medium": "Moyenne",
|
||||
"high": "Élevée",
|
||||
"capped": "(limité par l'administrateur)",
|
||||
"disabled": "L'administrateur a désactivé la réflexion approfondie."
|
||||
},
|
||||
"memoryLink": "Mémoire ({count})"
|
||||
},
|
||||
"relativeJustNow": "à l’instant",
|
||||
"relativeMinutesAgoOne": "il y a {count} minute",
|
||||
@@ -5991,7 +6068,6 @@
|
||||
"categoryGoal": "Objectifs",
|
||||
"categoryConstraint": "Contraintes",
|
||||
"categoryContext": "Contexte",
|
||||
"forget": "Oublier",
|
||||
"forgetAria": "Oublier cette information",
|
||||
"forgetTitle": "Oublier ce fait ?",
|
||||
"forgetBody": "Le Coach cesse de l’utiliser dès le message suivant. Un fait oublié ne peut pas être récupéré.",
|
||||
@@ -6007,7 +6083,8 @@
|
||||
"forgotError": "Impossible d'oublier cette information. Veuillez réessayer.",
|
||||
"clearedToast": "{count} informations enregistrées oubliées.",
|
||||
"clearError": "Impossible d'effacer les informations enregistrées. Veuillez réessayer.",
|
||||
"detail": "Il s'en sert pour garder ses réponses pertinentes. Supprimez tout ce qu'il vaut mieux qu'il oublie."
|
||||
"detail": "Il s'en sert pour garder ses réponses pertinentes. Supprimez tout ce qu'il vaut mieux qu'il oublie.",
|
||||
"categoryMedication": "Médicaments"
|
||||
},
|
||||
"coachReminders": {
|
||||
"title": "Tes rappels",
|
||||
@@ -6123,7 +6200,8 @@
|
||||
"low": "Faible",
|
||||
"medium": "Moyen",
|
||||
"high": "Élevé"
|
||||
}
|
||||
},
|
||||
"coachNote": "Dans le Coach, c'est la profondeur de réflexion des réglages du Coach qui s'applique."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -6662,6 +6740,13 @@
|
||||
"savedOn": "Coach activé.",
|
||||
"savedOff": "Coach masqué.",
|
||||
"saveError": "Impossible d enregistrer la modification. Veuillez réessayer."
|
||||
},
|
||||
"memory": {
|
||||
"edit": "Modifier",
|
||||
"source": {
|
||||
"user": "de toi",
|
||||
"coach": "du Coach"
|
||||
}
|
||||
}
|
||||
},
|
||||
"integrationRedirectGuide": {
|
||||
@@ -7157,6 +7242,11 @@
|
||||
"documentAi": {
|
||||
"title": "Lecture de documents",
|
||||
"description": "Lecture par IA des documents enregistrés, scan des comptes rendus de laboratoire et extraction de médicaments à partir de texte. La plus grande part des données qui quittent le serveur."
|
||||
},
|
||||
"reasoning": {
|
||||
"enabled": "Autoriser la réflexion approfondie",
|
||||
"max": "Profondeur de réflexion maximale",
|
||||
"description": "Les modèles réfléchissent avant de répondre : le Coach, le briefing quotidien et les synthèses de période. Plus précis, plus de jetons."
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
@@ -9780,9 +9870,6 @@
|
||||
"showResult": "Ouverture d'un tableau précédent",
|
||||
"reuse": "Reprise du tableau précédent",
|
||||
"headerActive": "{label}…",
|
||||
"headerDoneOne": "1 source consultée",
|
||||
"headerDoneFew": "{count} sources consultées",
|
||||
"headerDoneOther": "{count} sources consultées",
|
||||
"listLabel": "Ce que le Coach a consulté",
|
||||
"toggleShow": "Afficher ce que le Coach a consulté",
|
||||
"toggleHide": "Masquer ce que le Coach a consulté",
|
||||
@@ -9932,7 +10019,8 @@
|
||||
"relatedMetric": "Voir aussi : {metric}",
|
||||
"continue": "Continuer à chercher",
|
||||
"settingLabel": "Suggérer des questions de suivi",
|
||||
"settingHint": "Affiche jusqu'à trois suggestions sous la dernière réponse du Coach."
|
||||
"settingHint": "Affiche jusqu'à trois suggestions sous la dernière réponse du Coach.",
|
||||
"changeAssumption": "À la place : {value}"
|
||||
},
|
||||
"clarify": {
|
||||
"cardLabel": "Le Coach a une question",
|
||||
@@ -9945,6 +10033,12 @@
|
||||
"last90days": "90 derniers jours",
|
||||
"lastYear": "12 derniers mois",
|
||||
"allTime": "Toute la période"
|
||||
},
|
||||
"comparison": {
|
||||
"baseline90d": "Comparer avec mes 90 jours habituels"
|
||||
},
|
||||
"anchor": {
|
||||
"illness": "Depuis la maladie du {date}"
|
||||
}
|
||||
},
|
||||
"reuse": {
|
||||
|
||||
+109
-15
@@ -407,6 +407,7 @@
|
||||
"mood": "Umore",
|
||||
"coach": "Coach",
|
||||
"skipToContent": "Vai al contenuto",
|
||||
"breadcrumb": "Sei qui",
|
||||
"sidebar": "Barra laterale",
|
||||
"mainNavigation": "Navigazione principale",
|
||||
"mobileNavigation": "Navigazione mobile",
|
||||
@@ -2796,6 +2797,7 @@
|
||||
"refusalInjection": "Nel tuo messaggio ci sono istruzioni che cercano di sovrascrivere le mie indicazioni. Sono il coach di HealthLog e riassumo solo i tuoi dati di salute — riformula la domanda in questo perimetro.",
|
||||
"errorBudget": "Hai raggiunto il limite giornaliero del coach. Il budget si reimposta a mezzanotte (UTC).",
|
||||
"errorProvider": "Il coach non è riuscito a contattare un provider di analisi ora. Riprova fra poco.",
|
||||
"errorDemo": "Nella demo il Coach non risponde.",
|
||||
"errorNoProvider": "Il coach legge i tuoi dati di salute registrati e risponde a domande su di essi (non è consulenza medica). HealthLog ha bisogno di un provider IA per questo: collega la tua chiave o un modello locale.",
|
||||
"errorNoProviderAction": "Collega un provider IA",
|
||||
"errorNetwork": "Nessuna connessione Internet — riprova quando sei di nuovo online.",
|
||||
@@ -2835,9 +2837,8 @@
|
||||
},
|
||||
"rememberMessage": {
|
||||
"action": "Memorizza",
|
||||
"done": "Salvato nelle tue informazioni personali",
|
||||
"duplicate": "Già presente nelle tue informazioni personali",
|
||||
"failed": "Salvataggio non riuscito. Riprova."
|
||||
"failed": "Salvataggio non riuscito. Riprova.",
|
||||
"alreadyKept": "Il Coach lo ricorda già."
|
||||
},
|
||||
"guided": {
|
||||
"offerTitle": "Il Coach ha {count} domande per te",
|
||||
@@ -3018,7 +3019,6 @@
|
||||
"hideConversations": "Nascondi conversazioni",
|
||||
"heroGreeting": "Chiedimi qualcosa sui tuoi dati",
|
||||
"heroSubline": "Chiedi dei tuoi andamenti, farmaci o misurazioni.",
|
||||
"thinkingElapsed": "{seconds}s",
|
||||
"thinkingDuration": "Ha riflettuto per {seconds}s",
|
||||
"thinkingExpandAria": "Mostra cosa ha considerato il Coach",
|
||||
"thinkingDetail": "Risposta basata sul tuo riepilogo di salute.",
|
||||
@@ -3029,6 +3029,18 @@
|
||||
"baselineWithin": "Il tuo intervallo abituale è {low}–{high} {unit}; oggi {value} rientra al suo interno.",
|
||||
"baselineAbove": "Il tuo intervallo abituale è {low}–{high} {unit}; oggi {value} si colloca al di sopra.",
|
||||
"baselineBelow": "Il tuo intervallo abituale è {low}–{high} {unit}; oggi {value} si colloca al di sotto.",
|
||||
"sameHoursWithin": "A quest'ora del giorno di solito sei a {low}–{high} {unit}; oggi finora {value} rientra nell'intervallo.",
|
||||
"sameHoursAbove": "A quest'ora del giorno di solito sei a {low}–{high} {unit}; oggi finora {value} si colloca al di sopra.",
|
||||
"sameHoursBelow": "A quest'ora del giorno di solito sei a {low}–{high} {unit}; oggi finora {value} si colloca al di sotto.",
|
||||
"steadyWithin": "I tuoi valori sono di solito {low} {unit}; oggi {value} corrisponde.",
|
||||
"steadyAbove": "I tuoi valori sono di solito {low} {unit}; oggi {value} è al di sopra.",
|
||||
"steadyBelow": "I tuoi valori sono di solito {low} {unit}; oggi {value} è al di sotto.",
|
||||
"latestWithin": "Il tuo intervallo abituale è {low}–{high} {unit}; la tua ultima misurazione, del {date}, era {value}, al suo interno.",
|
||||
"latestAbove": "Il tuo intervallo abituale è {low}–{high} {unit}; la tua ultima misurazione, del {date}, era {value}, al di sopra.",
|
||||
"latestBelow": "Il tuo intervallo abituale è {low}–{high} {unit}; la tua ultima misurazione, del {date}, era {value}, al di sotto.",
|
||||
"steadyLatestWithin": "I tuoi valori sono di solito {low} {unit}; la tua ultima misurazione, del {date}, era {value}, in linea.",
|
||||
"steadyLatestAbove": "I tuoi valori sono di solito {low} {unit}; la tua ultima misurazione, del {date}, era {value}, al di sopra.",
|
||||
"steadyLatestBelow": "I tuoi valori sono di solito {low} {unit}; la tua ultima misurazione, del {date}, era {value}, al di sotto.",
|
||||
"insufficient": "Sto ancora imparando il tuo intervallo abituale — continua a registrare per qualche giorno in più.",
|
||||
"driver": "{note}"
|
||||
},
|
||||
@@ -3140,12 +3152,77 @@
|
||||
"rememberDone": "Memorizzato",
|
||||
"timeYesterday": "ieri {time}",
|
||||
"timeEarlier": "{date}, {time}",
|
||||
"areasDoneOne": "1 area consultata",
|
||||
"areasDoneFew": "{count} aree consultate",
|
||||
"areasDoneOther": "{count} aree consultate",
|
||||
"info": "Dettagli della risposta",
|
||||
"infoModel": "Modello: {model}"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"thinking": "Sta pensando…",
|
||||
"fetching": "Recupera {domain}, {window}…",
|
||||
"digestOne": "Riassume {count} valore…",
|
||||
"digestFew": "Riassume {count} valori…",
|
||||
"digestOther": "Riassume {count} valori…",
|
||||
"digestDoneOne": "{count} valore da {areas}",
|
||||
"digestDoneFew": "{count} valori da {areas}",
|
||||
"digestDoneOther": "{count} valori da {areas}",
|
||||
"areasOne": "{count} area",
|
||||
"areasFew": "{count} aree",
|
||||
"areasOther": "{count} aree",
|
||||
"memoryOne": "Ricorda {count} cosa che gli hai detto",
|
||||
"memoryFew": "Ricorda {count} cose che gli hai detto",
|
||||
"memoryOther": "Ricorda {count} cose che gli hai detto",
|
||||
"remember": "Memorizza qualcosa…",
|
||||
"plan": "Prepara un piano…",
|
||||
"asking": "Ti fa una domanda…",
|
||||
"answer": "Scrive la risposta…",
|
||||
"stop": {
|
||||
"budget": "Budget raggiunto, risponde con ciò che ha",
|
||||
"time": "Tempo scaduto, risponde con ciò che ha",
|
||||
"cap": "Molti passaggi, ora riassume",
|
||||
"noProgress": "Nessun dato nuovo, riassume"
|
||||
},
|
||||
"thoughtProcessOne": "Ragionamento · {count} passaggio",
|
||||
"thoughtProcessFew": "Ragionamento · {count} passaggi",
|
||||
"thoughtProcessOther": "Ragionamento · {count} passaggi",
|
||||
"awaitingAnswer": "Serve una tua risposta"
|
||||
},
|
||||
"memory": {
|
||||
"saved": "Memorizzato: {fact}",
|
||||
"undo": "Annulla",
|
||||
"accept": "Sì, ricordalo",
|
||||
"decline": "No",
|
||||
"confirmed": "Ricevuto, me lo ricorderò.",
|
||||
"declined": "D'accordo, non lo faccio."
|
||||
},
|
||||
"plan": {
|
||||
"accept": "Adotta questo piano",
|
||||
"decline": "Non ora",
|
||||
"confirmed": "Annotato. Ne riparliamo insieme tra {days} giorni.",
|
||||
"declined": "D'accordo, non ora."
|
||||
},
|
||||
"assumption": {
|
||||
"line": "Ipotesi: {value}",
|
||||
"change": "Cambia"
|
||||
},
|
||||
"suggestedReplies": {
|
||||
"groupLabel": "Risposte suggerite"
|
||||
},
|
||||
"chart": {
|
||||
"compare": {
|
||||
"periods": "{a} rispetto a {b}"
|
||||
}
|
||||
},
|
||||
"reasoning": {
|
||||
"label": "Profondità di ragionamento",
|
||||
"hint": "Pensare più a fondo significa risposte più precise e un po' più di attesa.",
|
||||
"off": "Disattivato",
|
||||
"minimal": "Minima",
|
||||
"low": "Bassa",
|
||||
"medium": "Media",
|
||||
"high": "Alta",
|
||||
"capped": "(limitato dall'amministratore)",
|
||||
"disabled": "L'amministratore ha disattivato il ragionamento approfondito."
|
||||
},
|
||||
"memoryLink": "Memoria ({count})"
|
||||
},
|
||||
"relativeJustNow": "proprio ora",
|
||||
"relativeMinutesAgoOne": "{count} minuto fa",
|
||||
@@ -5991,7 +6068,6 @@
|
||||
"categoryGoal": "Obiettivi",
|
||||
"categoryConstraint": "Vincoli",
|
||||
"categoryContext": "Contesto",
|
||||
"forget": "Dimentica",
|
||||
"forgetAria": "Dimentica questa informazione",
|
||||
"forgetTitle": "Dimenticare questa informazione?",
|
||||
"forgetBody": "Il Coach smette di usarla dal messaggio successivo. Un’informazione dimenticata non può essere recuperata.",
|
||||
@@ -6007,7 +6083,8 @@
|
||||
"forgotError": "Impossibile dimenticare questa informazione. Riprova.",
|
||||
"clearedToast": "Dimenticate {count} informazioni memorizzate.",
|
||||
"clearError": "Impossibile cancellare le informazioni memorizzate. Riprova.",
|
||||
"detail": "Le usa per mantenere pertinenti le sue risposte. Rimuovi tutto ciò che preferisci dimentichi."
|
||||
"detail": "Le usa per mantenere pertinenti le sue risposte. Rimuovi tutto ciò che preferisci dimentichi.",
|
||||
"categoryMedication": "Farmaci"
|
||||
},
|
||||
"coachReminders": {
|
||||
"title": "I tuoi promemoria",
|
||||
@@ -6123,7 +6200,8 @@
|
||||
"low": "Basso",
|
||||
"medium": "Medio",
|
||||
"high": "Alto"
|
||||
}
|
||||
},
|
||||
"coachNote": "Nel Coach vale la profondità di ragionamento delle impostazioni del Coach."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -6662,6 +6740,13 @@
|
||||
"savedOn": "Coach attivato.",
|
||||
"savedOff": "Coach nascosto.",
|
||||
"saveError": "Impossibile salvare la modifica. Riprova."
|
||||
},
|
||||
"memory": {
|
||||
"edit": "Modifica",
|
||||
"source": {
|
||||
"user": "da te",
|
||||
"coach": "dal Coach"
|
||||
}
|
||||
}
|
||||
},
|
||||
"integrationRedirectGuide": {
|
||||
@@ -7157,6 +7242,11 @@
|
||||
"documentAi": {
|
||||
"title": "Lettura dei documenti",
|
||||
"description": "Lettura con IA dei documenti salvati, scansione dei referti di laboratorio ed estrazione dei farmaci dal testo. La quota maggiore dei dati che lasciano il server."
|
||||
},
|
||||
"reasoning": {
|
||||
"enabled": "Consenti ragionamento approfondito",
|
||||
"max": "Profondità massima di ragionamento",
|
||||
"description": "I modelli riflettono prima di rispondere: il Coach, il briefing giornaliero e i riepiloghi di periodo. Più precisi, usano più token."
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
@@ -9780,9 +9870,6 @@
|
||||
"showResult": "Apertura di una tabella precedente",
|
||||
"reuse": "Uso della tabella di prima",
|
||||
"headerActive": "{label}…",
|
||||
"headerDoneOne": "1 fonte consultata",
|
||||
"headerDoneFew": "{count} fonti consultate",
|
||||
"headerDoneOther": "{count} fonti consultate",
|
||||
"listLabel": "Cosa ha consultato il Coach",
|
||||
"toggleShow": "Mostra cosa ha consultato il Coach",
|
||||
"toggleHide": "Nascondi cosa ha consultato il Coach",
|
||||
@@ -9932,7 +10019,8 @@
|
||||
"relatedMetric": "Vedi anche: {metric}",
|
||||
"continue": "Continua a cercare",
|
||||
"settingLabel": "Suggerisci domande di approfondimento",
|
||||
"settingHint": "Mostra fino a tre suggerimenti sotto l'ultima risposta del Coach."
|
||||
"settingHint": "Mostra fino a tre suggerimenti sotto l'ultima risposta del Coach.",
|
||||
"changeAssumption": "Invece: {value}"
|
||||
},
|
||||
"clarify": {
|
||||
"cardLabel": "Il Coach ha una domanda",
|
||||
@@ -9945,6 +10033,12 @@
|
||||
"last90days": "Ultimi 90 giorni",
|
||||
"lastYear": "Ultimi 12 mesi",
|
||||
"allTime": "Intero periodo"
|
||||
},
|
||||
"comparison": {
|
||||
"baseline90d": "Confronta con i miei soliti 90 giorni"
|
||||
},
|
||||
"anchor": {
|
||||
"illness": "Dalla malattia del {date}"
|
||||
}
|
||||
},
|
||||
"reuse": {
|
||||
|
||||
+109
-15
@@ -407,6 +407,7 @@
|
||||
"mood": "기분",
|
||||
"coach": "코치",
|
||||
"skipToContent": "본문으로 건너뛰기",
|
||||
"breadcrumb": "현재 위치",
|
||||
"sidebar": "사이드바",
|
||||
"mainNavigation": "주 메뉴",
|
||||
"mobileNavigation": "모바일 메뉴",
|
||||
@@ -2796,6 +2797,7 @@
|
||||
"refusalInjection": "제 지시를 덮어쓰려는 표현이 보였어요. 저는 HealthLog 코치이고 회원님의 건강 데이터만 정리해 드려요 — 그 범위로 질문을 다시 적어 주세요.",
|
||||
"errorBudget": "오늘 코치 사용 한도에 도달했어요. 사용량은 UTC 자정에 초기화돼요.",
|
||||
"errorProvider": "지금은 코치가 인사이트 제공자에 연결하지 못했어요. 잠시 후 다시 시도해 주세요.",
|
||||
"errorDemo": "데모에서는 코치가 답하지 않아요.",
|
||||
"errorNoProvider": "코치는 기록된 건강 데이터를 읽고 그에 대한 질문에 답해요(의학적 조언은 아니에요). 그러려면 HealthLog에 AI 제공자가 필요해요 — 직접 발급한 키나 로컬 모델을 연결해 주세요.",
|
||||
"errorNoProviderAction": "AI 제공자 연결",
|
||||
"errorNetwork": "인터넷에 연결되어 있지 않아요 — 다시 연결되면 시도해 주세요.",
|
||||
@@ -2835,9 +2837,8 @@
|
||||
},
|
||||
"rememberMessage": {
|
||||
"action": "기억하기",
|
||||
"done": "셀프 컨텍스트에 저장했어요",
|
||||
"duplicate": "이미 셀프 컨텍스트에 있어요",
|
||||
"failed": "저장하지 못했어요. 다시 시도해 주세요."
|
||||
"failed": "저장하지 못했어요. 다시 시도해 주세요.",
|
||||
"alreadyKept": "코치가 이미 기억하고 있어요."
|
||||
},
|
||||
"guided": {
|
||||
"offerTitle": "코치가 물어볼 후속 질문이 {count}개 있어요",
|
||||
@@ -3018,7 +3019,6 @@
|
||||
"hideConversations": "대화 숨기기",
|
||||
"heroGreeting": "내 데이터에 대해 무엇이든 물어보세요",
|
||||
"heroSubline": "추이, 약, 측정값에 대해 물어보세요.",
|
||||
"thinkingElapsed": "{seconds}초",
|
||||
"thinkingDuration": "{seconds}초 동안 생각했어요",
|
||||
"thinkingExpandAria": "코치가 참고한 내용 보기",
|
||||
"thinkingDetail": "건강 스냅샷을 바탕으로 답했어요.",
|
||||
@@ -3029,6 +3029,18 @@
|
||||
"baselineWithin": "평소 범위: {low}–{high} {unit}. 오늘 값 {value} — 범위 안이에요.",
|
||||
"baselineAbove": "평소 범위: {low}–{high} {unit}. 오늘 값 {value} — 범위 위예요.",
|
||||
"baselineBelow": "평소 범위: {low}–{high} {unit}. 오늘 값 {value} — 범위 아래예요.",
|
||||
"sameHoursWithin": "이 시간대까지 평소 범위: {low}–{high} {unit}. 오늘 지금까지 {value} — 범위 안이에요.",
|
||||
"sameHoursAbove": "이 시간대까지 평소 범위: {low}–{high} {unit}. 오늘 지금까지 {value} — 범위 위예요.",
|
||||
"sameHoursBelow": "이 시간대까지 평소 범위: {low}–{high} {unit}. 오늘 지금까지 {value} — 범위 아래예요.",
|
||||
"steadyWithin": "평소 값: {low} {unit}. 오늘 값 {value} — 평소와 같아요.",
|
||||
"steadyAbove": "평소 값: {low} {unit}. 오늘 값 {value} — 평소보다 높아요.",
|
||||
"steadyBelow": "평소 값: {low} {unit}. 오늘 값 {value} — 평소보다 낮아요.",
|
||||
"latestWithin": "평소 범위: {low}–{high} {unit}. 마지막 측정({date}) 값 {value} — 범위 안이었어요.",
|
||||
"latestAbove": "평소 범위: {low}–{high} {unit}. 마지막 측정({date}) 값 {value} — 범위보다 높았어요.",
|
||||
"latestBelow": "평소 범위: {low}–{high} {unit}. 마지막 측정({date}) 값 {value} — 범위 아래였어요.",
|
||||
"steadyLatestWithin": "평소 값: {low} {unit}. 마지막 측정({date}) 값 {value} — 평소와 같았어요.",
|
||||
"steadyLatestAbove": "평소 값: {low} {unit}. 마지막 측정({date}) 값 {value} — 평소보다 높았어요.",
|
||||
"steadyLatestBelow": "평소 값: {low} {unit}. 마지막 측정({date}) 값 {value} — 평소보다 낮았어요.",
|
||||
"insufficient": "아직 평소 범위를 배우는 중이에요 — 며칠 더 기록해 주세요.",
|
||||
"driver": "{note}"
|
||||
},
|
||||
@@ -3140,12 +3152,77 @@
|
||||
"rememberDone": "기억함",
|
||||
"timeYesterday": "어제 {time}",
|
||||
"timeEarlier": "{date} {time}",
|
||||
"areasDoneOne": "영역 1개 확인",
|
||||
"areasDoneFew": "영역 {count}개 확인",
|
||||
"areasDoneOther": "영역 {count}개 확인",
|
||||
"info": "답변 정보",
|
||||
"infoModel": "모델: {model}"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"thinking": "생각하는 중…",
|
||||
"fetching": "{domain} 가져오는 중, {window}…",
|
||||
"digestOne": "값 {count}개 요약 중…",
|
||||
"digestFew": "값 {count}개 요약 중…",
|
||||
"digestOther": "값 {count}개 요약 중…",
|
||||
"digestDoneOne": "{areas}에서 값 {count}개",
|
||||
"digestDoneFew": "{areas}에서 값 {count}개",
|
||||
"digestDoneOther": "{areas}에서 값 {count}개",
|
||||
"areasOne": "{count}개 영역",
|
||||
"areasFew": "{count}개 영역",
|
||||
"areasOther": "{count}개 영역",
|
||||
"memoryOne": "알려 준 내용 {count}개를 떠올리는 중",
|
||||
"memoryFew": "알려 준 내용 {count}개를 떠올리는 중",
|
||||
"memoryOther": "알려 준 내용 {count}개를 떠올리는 중",
|
||||
"remember": "기억해 두는 중…",
|
||||
"plan": "계획을 제안하는 중…",
|
||||
"asking": "질문하는 중…",
|
||||
"answer": "답변을 쓰는 중…",
|
||||
"stop": {
|
||||
"budget": "예산 도달, 지금까지의 내용으로 답변",
|
||||
"time": "시간 제한 도달, 지금까지의 내용으로 답변",
|
||||
"cap": "단계가 많아 이제 정리하는 중",
|
||||
"noProgress": "새 데이터가 없어 정리하는 중"
|
||||
},
|
||||
"thoughtProcessOne": "사고 과정 · {count}단계",
|
||||
"thoughtProcessFew": "사고 과정 · {count}단계",
|
||||
"thoughtProcessOther": "사고 과정 · {count}단계",
|
||||
"awaitingAnswer": "답변이 필요해요"
|
||||
},
|
||||
"memory": {
|
||||
"saved": "기억함: {fact}",
|
||||
"undo": "실행 취소",
|
||||
"accept": "네, 기억해 줘",
|
||||
"decline": "아니요",
|
||||
"confirmed": "알겠어요, 기억해 둘게요.",
|
||||
"declined": "알겠어요, 저장하지 않을게요."
|
||||
},
|
||||
"plan": {
|
||||
"accept": "이 계획 시작하기",
|
||||
"decline": "나중에",
|
||||
"confirmed": "기록했어요. {days}일 뒤에 함께 살펴볼게요.",
|
||||
"declined": "알겠어요, 지금은 하지 않을게요."
|
||||
},
|
||||
"assumption": {
|
||||
"line": "가정: {value}",
|
||||
"change": "변경"
|
||||
},
|
||||
"suggestedReplies": {
|
||||
"groupLabel": "추천 답변"
|
||||
},
|
||||
"chart": {
|
||||
"compare": {
|
||||
"periods": "{a} 대 {b}"
|
||||
}
|
||||
},
|
||||
"reasoning": {
|
||||
"label": "생각 깊이",
|
||||
"hint": "더 깊이 생각할수록 답변이 정확해지고 조금 더 기다리게 돼요.",
|
||||
"off": "끄기",
|
||||
"minimal": "최소",
|
||||
"low": "낮음",
|
||||
"medium": "중간",
|
||||
"high": "높음",
|
||||
"capped": "(관리자가 제한함)",
|
||||
"disabled": "관리자가 깊은 생각을 꺼 두었어요."
|
||||
},
|
||||
"memoryLink": "기억 ({count})"
|
||||
},
|
||||
"relativeJustNow": "방금",
|
||||
"relativeMinutesAgoOne": "{count}분 전",
|
||||
@@ -5991,7 +6068,6 @@
|
||||
"categoryGoal": "목표",
|
||||
"categoryConstraint": "제약",
|
||||
"categoryContext": "맥락",
|
||||
"forget": "잊기",
|
||||
"forgetAria": "이 정보 잊기",
|
||||
"forgetTitle": "이 정보를 잊을까요?",
|
||||
"forgetBody": "다음 메시지부터 코치가 이 정보를 쓰지 않아요. 한번 잊은 정보는 되돌릴 수 없어요.",
|
||||
@@ -6007,7 +6083,8 @@
|
||||
"forgotError": "그 정보를 잊지 못했어요. 다시 시도해 주세요.",
|
||||
"clearedToast": "저장된 정보 {count}개를 잊었어요.",
|
||||
"clearError": "저장된 정보를 지우지 못했어요. 다시 시도해 주세요.",
|
||||
"detail": "코치는 이 정보로 답변을 상황에 맞게 유지해요. 잊었으면 하는 건 지우세요."
|
||||
"detail": "코치는 이 정보로 답변을 상황에 맞게 유지해요. 잊었으면 하는 건 지우세요.",
|
||||
"categoryMedication": "약"
|
||||
},
|
||||
"coachReminders": {
|
||||
"title": "내 알림",
|
||||
@@ -6123,7 +6200,8 @@
|
||||
"low": "낮음",
|
||||
"medium": "보통",
|
||||
"high": "높음"
|
||||
}
|
||||
},
|
||||
"coachNote": "코치에서는 코치 설정의 사고 깊이가 적용됩니다."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -6662,6 +6740,13 @@
|
||||
"savedOn": "코치를 켰어요.",
|
||||
"savedOff": "코치를 숨겼어요.",
|
||||
"saveError": "변경 사항을 저장하지 못했어요. 다시 시도해 주세요."
|
||||
},
|
||||
"memory": {
|
||||
"edit": "편집",
|
||||
"source": {
|
||||
"user": "내가 알려 줌",
|
||||
"coach": "코치가 기억함"
|
||||
}
|
||||
}
|
||||
},
|
||||
"integrationRedirectGuide": {
|
||||
@@ -7136,6 +7221,11 @@
|
||||
"documentAi": {
|
||||
"title": "문서 읽기",
|
||||
"description": "저장된 문서의 AI 읽기, 검사 결과지 스캔, 텍스트에서 약물 추출. 서버 밖으로 나가는 데이터의 대부분을 차지합니다."
|
||||
},
|
||||
"reasoning": {
|
||||
"enabled": "깊은 생각 허용",
|
||||
"max": "최대 생각 깊이",
|
||||
"description": "모델이 답하기 전에 생각합니다: 코치, 일일 브리핑, 기간 요약. 더 정확하지만 토큰을 더 씁니다."
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
@@ -9780,9 +9870,6 @@
|
||||
"showResult": "이전 표 여는 중",
|
||||
"reuse": "앞서 만든 표 사용 중",
|
||||
"headerActive": "{label}…",
|
||||
"headerDoneOne": "출처 1개 확인함",
|
||||
"headerDoneFew": "출처 {count}개 확인함",
|
||||
"headerDoneOther": "출처 {count}개 확인함",
|
||||
"listLabel": "코치가 확인한 내용",
|
||||
"toggleShow": "코치가 확인한 내용 보기",
|
||||
"toggleHide": "코치가 확인한 내용 숨기기",
|
||||
@@ -9932,7 +10019,8 @@
|
||||
"relatedMetric": "함께 보기: {metric}",
|
||||
"continue": "계속 찾아보기",
|
||||
"settingLabel": "후속 질문 추천",
|
||||
"settingHint": "코치의 가장 최근 답변 아래에 후속 질문을 최대 세 개까지 보여줘요."
|
||||
"settingHint": "코치의 가장 최근 답변 아래에 후속 질문을 최대 세 개까지 보여줘요.",
|
||||
"changeAssumption": "대신: {value}"
|
||||
},
|
||||
"clarify": {
|
||||
"cardLabel": "코치가 질문이 있어요",
|
||||
@@ -9945,6 +10033,12 @@
|
||||
"last90days": "최근 90일",
|
||||
"lastYear": "최근 12개월",
|
||||
"allTime": "전체 기간"
|
||||
},
|
||||
"comparison": {
|
||||
"baseline90d": "평소 90일과 비교하기"
|
||||
},
|
||||
"anchor": {
|
||||
"illness": "{date} 질환 이후부터"
|
||||
}
|
||||
},
|
||||
"reuse": {
|
||||
|
||||
+109
-15
@@ -407,6 +407,7 @@
|
||||
"mood": "Nastrój",
|
||||
"coach": "Coach",
|
||||
"skipToContent": "Przejdź do treści",
|
||||
"breadcrumb": "Jesteś tutaj",
|
||||
"sidebar": "Pasek boczny",
|
||||
"mainNavigation": "Nawigacja główna",
|
||||
"mobileNavigation": "Nawigacja mobilna",
|
||||
@@ -2796,6 +2797,7 @@
|
||||
"refusalInjection": "Twoja wiadomość zawiera instrukcje, które próbują nadpisać moje wytyczne. Jestem coachem HealthLog i podsumowuję wyłącznie twoje własne dane zdrowotne — przeformułuj pytanie w tych ramach.",
|
||||
"errorBudget": "Osiągnięto dzienny limit coacha. Limit resetuje się o północy (UTC).",
|
||||
"errorProvider": "Coach nie mógł teraz skontaktować się z dostawcą analizy. Spróbuj za chwilę.",
|
||||
"errorDemo": "W wersji demo Coach nie odpowiada.",
|
||||
"errorNoProvider": "Coach czyta Twoje zapisane dane zdrowotne i odpowiada na pytania o nie (to nie porada medyczna). HealthLog potrzebuje do tego dostawcy AI — połącz własny klucz lub model lokalny.",
|
||||
"errorNoProviderAction": "Połącz dostawcę AI",
|
||||
"errorNetwork": "Brak połączenia z Internetem — spróbuj ponownie, gdy wrócisz online.",
|
||||
@@ -2835,9 +2837,8 @@
|
||||
},
|
||||
"rememberMessage": {
|
||||
"action": "Zapamiętaj",
|
||||
"done": "Zapisano w informacjach o sobie",
|
||||
"duplicate": "Już znajduje się w informacjach o sobie",
|
||||
"failed": "Nie udało się zapisać. Spróbuj ponownie."
|
||||
"failed": "Nie udało się zapisać. Spróbuj ponownie.",
|
||||
"alreadyKept": "Coach już to pamięta."
|
||||
},
|
||||
"guided": {
|
||||
"offerTitle": "Coach ma {count} pytania do Ciebie",
|
||||
@@ -3018,7 +3019,6 @@
|
||||
"hideConversations": "Ukryj rozmowy",
|
||||
"heroGreeting": "Zapytaj mnie o cokolwiek na temat twoich danych",
|
||||
"heroSubline": "Zapytaj o swoje trendy, leki lub pomiary.",
|
||||
"thinkingElapsed": "{seconds}s",
|
||||
"thinkingDuration": "Myślał przez {seconds}s",
|
||||
"thinkingExpandAria": "Pokaż, co Coach wziął pod uwagę",
|
||||
"thinkingDetail": "Odpowiedź na podstawie Twojego podsumowania zdrowia.",
|
||||
@@ -3029,6 +3029,18 @@
|
||||
"baselineWithin": "Twój zwykły zakres to {low}–{high} {unit}; dzisiejsza wartość {value} mieści się w nim.",
|
||||
"baselineAbove": "Twój zwykły zakres to {low}–{high} {unit}; dzisiejsza wartość {value} jest powyżej niego.",
|
||||
"baselineBelow": "Twój zwykły zakres to {low}–{high} {unit}; dzisiejsza wartość {value} jest poniżej niego.",
|
||||
"sameHoursWithin": "Do tej pory dnia zwykle masz {low}–{high} {unit}; dziś jak dotąd {value}, czyli w zakresie.",
|
||||
"sameHoursAbove": "Do tej pory dnia zwykle masz {low}–{high} {unit}; dziś jak dotąd {value}, czyli powyżej.",
|
||||
"sameHoursBelow": "Do tej pory dnia zwykle masz {low}–{high} {unit}; dziś jak dotąd {value}, czyli poniżej.",
|
||||
"steadyWithin": "Twoje pomiary zwykle wynoszą {low} {unit}; dzisiejsza wartość {value} jest taka sama.",
|
||||
"steadyAbove": "Twoje pomiary zwykle wynoszą {low} {unit}; dzisiejsza wartość {value} jest wyższa.",
|
||||
"steadyBelow": "Twoje pomiary zwykle wynoszą {low} {unit}; dzisiejsza wartość {value} jest niższa.",
|
||||
"latestWithin": "Twój zwykły zakres to {low}–{high} {unit}; ostatni pomiar, z {date}, wyniósł {value} i mieścił się w nim.",
|
||||
"latestAbove": "Twój zwykły zakres to {low}–{high} {unit}; ostatni pomiar, z {date}, wyniósł {value} i był powyżej niego.",
|
||||
"latestBelow": "Twój zwykły zakres to {low}–{high} {unit}; ostatni pomiar, z {date}, wyniósł {value} i był poniżej niego.",
|
||||
"steadyLatestWithin": "Twoje pomiary zwykle wynoszą {low} {unit}; ostatni pomiar, z {date}, wyniósł {value} i był taki sam.",
|
||||
"steadyLatestAbove": "Twoje pomiary zwykle wynoszą {low} {unit}; ostatni pomiar, z {date}, wyniósł {value} i był wyższy.",
|
||||
"steadyLatestBelow": "Twoje pomiary zwykle wynoszą {low} {unit}; ostatni pomiar, z {date}, wyniósł {value} i był niższy.",
|
||||
"insufficient": "Wciąż poznaję twój zwykły zakres — rejestruj jeszcze przez kilka dni.",
|
||||
"driver": "{note}"
|
||||
},
|
||||
@@ -3140,12 +3152,77 @@
|
||||
"rememberDone": "Zapamiętano",
|
||||
"timeYesterday": "wczoraj {time}",
|
||||
"timeEarlier": "{date}, {time}",
|
||||
"areasDoneOne": "Sprawdzono 1 obszar",
|
||||
"areasDoneFew": "Sprawdzono {count} obszary",
|
||||
"areasDoneOther": "Sprawdzono {count} obszarów",
|
||||
"info": "Szczegóły odpowiedzi",
|
||||
"infoModel": "Model: {model}"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"thinking": "Myśli…",
|
||||
"fetching": "Pobiera {domain}, {window}…",
|
||||
"digestOne": "Podsumowuje {count} pomiar…",
|
||||
"digestFew": "Podsumowuje {count} pomiary…",
|
||||
"digestOther": "Podsumowuje {count} pomiarów…",
|
||||
"digestDoneOne": "{count} pomiar z: {areas}",
|
||||
"digestDoneFew": "{count} pomiary z: {areas}",
|
||||
"digestDoneOther": "{count} pomiarów z: {areas}",
|
||||
"areasOne": "{count} obszar",
|
||||
"areasFew": "{count} obszary",
|
||||
"areasOther": "{count} obszarów",
|
||||
"memoryOne": "Pamięta {count} rzecz, którą mu powiedziałeś",
|
||||
"memoryFew": "Pamięta {count} rzeczy, które mu powiedziałeś",
|
||||
"memoryOther": "Pamięta {count} rzeczy, które mu powiedziałeś",
|
||||
"remember": "Zapamiętuje coś…",
|
||||
"plan": "Przygotowuje plan…",
|
||||
"asking": "Dopytuje…",
|
||||
"answer": "Pisze odpowiedź…",
|
||||
"stop": {
|
||||
"budget": "Budżet wyczerpany, odpowiada na podstawie tego, co ma",
|
||||
"time": "Limit czasu osiągnięty, odpowiada na podstawie tego, co ma",
|
||||
"cap": "Wiele kroków, teraz podsumowuje",
|
||||
"noProgress": "Brak nowych danych, podsumowuje"
|
||||
},
|
||||
"thoughtProcessOne": "Tok myślenia · {count} krok",
|
||||
"thoughtProcessFew": "Tok myślenia · {count} kroki",
|
||||
"thoughtProcessOther": "Tok myślenia · {count} kroków",
|
||||
"awaitingAnswer": "Potrzebna odpowiedź"
|
||||
},
|
||||
"memory": {
|
||||
"saved": "Zapamiętane: {fact}",
|
||||
"undo": "Cofnij",
|
||||
"accept": "Tak, zapamiętaj",
|
||||
"decline": "Nie",
|
||||
"confirmed": "Jasne, zapamiętam.",
|
||||
"declined": "Dobrze, nie zapiszę tego."
|
||||
},
|
||||
"plan": {
|
||||
"accept": "Przyjmij ten plan",
|
||||
"decline": "Nie teraz",
|
||||
"confirmed": "Zanotowane. Wrócimy do tego razem za {days} dni.",
|
||||
"declined": "Dobrze, nie teraz."
|
||||
},
|
||||
"assumption": {
|
||||
"line": "Założenie: {value}",
|
||||
"change": "Zmień"
|
||||
},
|
||||
"suggestedReplies": {
|
||||
"groupLabel": "Proponowane odpowiedzi"
|
||||
},
|
||||
"chart": {
|
||||
"compare": {
|
||||
"periods": "{a} wobec {b}"
|
||||
}
|
||||
},
|
||||
"reasoning": {
|
||||
"label": "Głębokość namysłu",
|
||||
"hint": "Głębszy namysł oznacza dokładniejsze odpowiedzi i trochę dłuższe czekanie.",
|
||||
"off": "Wyłączone",
|
||||
"minimal": "Minimalna",
|
||||
"low": "Niska",
|
||||
"medium": "Średnia",
|
||||
"high": "Wysoka",
|
||||
"capped": "(ograniczone przez administratora)",
|
||||
"disabled": "Administrator wyłączył głębszy namysł."
|
||||
},
|
||||
"memoryLink": "Pamięć ({count})"
|
||||
},
|
||||
"relativeJustNow": "przed chwilą",
|
||||
"relativeMinutesAgoOne": "{count} minutę temu",
|
||||
@@ -5991,7 +6068,6 @@
|
||||
"categoryGoal": "Cele",
|
||||
"categoryConstraint": "Ograniczenia",
|
||||
"categoryContext": "Kontekst",
|
||||
"forget": "Zapomnij",
|
||||
"forgetAria": "Zapomnij tę informację",
|
||||
"forgetTitle": "Zapomnieć tę informację?",
|
||||
"forgetBody": "Coach przestanie z niej korzystać od następnej wiadomości. Zapomnianej informacji nie da się odzyskać.",
|
||||
@@ -6007,7 +6083,8 @@
|
||||
"forgotError": "Nie udało się zapomnieć tej informacji. Spróbuj ponownie.",
|
||||
"clearedToast": "Zapomniano {count} zapisanych informacji.",
|
||||
"clearError": "Nie udało się usunąć zapisanych informacji. Spróbuj ponownie.",
|
||||
"detail": "Wykorzystuje je, aby jego odpowiedzi pozostawały trafne. Usuń wszystko, o czym wolisz, by zapomniał."
|
||||
"detail": "Wykorzystuje je, aby jego odpowiedzi pozostawały trafne. Usuń wszystko, o czym wolisz, by zapomniał.",
|
||||
"categoryMedication": "Leki"
|
||||
},
|
||||
"coachReminders": {
|
||||
"title": "Twoje przypomnienia",
|
||||
@@ -6123,7 +6200,8 @@
|
||||
"low": "Niskie",
|
||||
"medium": "Średnie",
|
||||
"high": "Wysokie"
|
||||
}
|
||||
},
|
||||
"coachNote": "W Coachu obowiązuje głębokość myślenia z ustawień Coacha."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -6662,6 +6740,13 @@
|
||||
"savedOn": "Coach włączony.",
|
||||
"savedOff": "Coach ukryty.",
|
||||
"saveError": "Nie udało się zapisać zmiany. Spróbuj ponownie."
|
||||
},
|
||||
"memory": {
|
||||
"edit": "Edytuj",
|
||||
"source": {
|
||||
"user": "od ciebie",
|
||||
"coach": "od Coacha"
|
||||
}
|
||||
}
|
||||
},
|
||||
"integrationRedirectGuide": {
|
||||
@@ -7157,6 +7242,11 @@
|
||||
"documentAi": {
|
||||
"title": "Czytanie dokumentów",
|
||||
"description": "Czytanie zapisanych dokumentów przez AI, skanowanie wyników badań laboratoryjnych i odczytywanie leków z tekstu. Największa część danych opuszczających serwer."
|
||||
},
|
||||
"reasoning": {
|
||||
"enabled": "Zezwalaj na głębszy namysł",
|
||||
"max": "Największa głębokość namysłu",
|
||||
"description": "Modele myślą przed odpowiedzią: Coach, codzienne podsumowanie i podsumowania okresów. Dokładniej, ale więcej tokenów."
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
@@ -9780,9 +9870,6 @@
|
||||
"showResult": "Otwieram wcześniejszą tabelę",
|
||||
"reuse": "Korzystam z tabeli z poprzedniej odpowiedzi",
|
||||
"headerActive": "{label}…",
|
||||
"headerDoneOne": "Sprawdzono 1 źródło",
|
||||
"headerDoneFew": "Sprawdzono {count} źródła",
|
||||
"headerDoneOther": "Sprawdzono {count} źródeł",
|
||||
"listLabel": "Co sprawdził Coach",
|
||||
"toggleShow": "Pokaż, co sprawdził Coach",
|
||||
"toggleHide": "Ukryj, co sprawdził Coach",
|
||||
@@ -9932,7 +10019,8 @@
|
||||
"relatedMetric": "Sprawdź też: {metric}",
|
||||
"continue": "Szukaj dalej",
|
||||
"settingLabel": "Proponuj kolejne pytania",
|
||||
"settingHint": "Pokazuje do trzech propozycji pod ostatnią odpowiedzią Coacha."
|
||||
"settingHint": "Pokazuje do trzech propozycji pod ostatnią odpowiedzią Coacha.",
|
||||
"changeAssumption": "Zamiast tego: {value}"
|
||||
},
|
||||
"clarify": {
|
||||
"cardLabel": "Coach ma pytanie",
|
||||
@@ -9945,6 +10033,12 @@
|
||||
"last90days": "Ostatnie 90 dni",
|
||||
"lastYear": "Ostatnie 12 miesięcy",
|
||||
"allTime": "Cały okres"
|
||||
},
|
||||
"comparison": {
|
||||
"baseline90d": "Porównaj z moimi zwykłymi 90 dniami"
|
||||
},
|
||||
"anchor": {
|
||||
"illness": "Od choroby z {date}"
|
||||
}
|
||||
},
|
||||
"reuse": {
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.40.4",
|
||||
"version": "1.41.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",
|
||||
@@ -59,7 +59,7 @@
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^5.9.1",
|
||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
"@modelcontextprotocol/sdk": "^1.32.1",
|
||||
"@napi-rs/canvas": "1.0.9",
|
||||
"@node-rs/argon2": "^2.1.0",
|
||||
"@parse/node-apn": "^8.1.0",
|
||||
@@ -123,6 +123,7 @@
|
||||
"@types/web-push": "^3.6.4",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"@vitest/coverage-v8": "^4.1.5",
|
||||
"ajv": "^8.20.0",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.3.8",
|
||||
"knip": "^6.32.3",
|
||||
|
||||
Generated
+15
-11
@@ -18,6 +18,7 @@ overrides:
|
||||
qs@<6.16.0: ^6.16.0
|
||||
fast-uri@<3.1.8: ^3.1.8
|
||||
flatted@<3.4.2: ^3.4.2
|
||||
proxy-addr@<2.0.8: ^2.0.8
|
||||
mysql2@<3.22.0: ^3.22.0
|
||||
minimatch@<3.1.4: ^3.1.4
|
||||
ip-address@<10.7.1: ^10.7.1
|
||||
@@ -56,8 +57,8 @@ importers:
|
||||
specifier: ^5.9.1
|
||||
version: 5.9.1(@standard-schema/spec@1.1.0)(ajv-formats@2.1.1(ajv@8.20.0))(ajv@8.20.0)(effect@3.20.0)(react-hook-form@7.83.0(react@19.3.0))(valibot@1.4.2(typescript@6.0.3))(zod@4.4.3)
|
||||
'@modelcontextprotocol/sdk':
|
||||
specifier: ^1.30.0
|
||||
version: 1.30.0(@cfworker/json-schema@4.1.1)(supports-color@7.2.0)(zod@4.4.3)
|
||||
specifier: ^1.32.1
|
||||
version: 1.32.1(@cfworker/json-schema@4.1.1)(supports-color@7.2.0)(zod@4.4.3)
|
||||
'@napi-rs/canvas':
|
||||
specifier: 1.0.9
|
||||
version: 1.0.9
|
||||
@@ -242,6 +243,9 @@ importers:
|
||||
'@vitest/coverage-v8':
|
||||
specifier: ^4.1.5
|
||||
version: 4.1.5(vitest@4.1.11)
|
||||
ajv:
|
||||
specifier: ^8.20.0
|
||||
version: 8.20.0
|
||||
eslint:
|
||||
specifier: ^9
|
||||
version: 9.39.3(jiti@2.7.0)(supports-color@7.2.0)
|
||||
@@ -1036,8 +1040,8 @@ packages:
|
||||
'@levischuck/tiny-cbor@0.2.11':
|
||||
resolution: {integrity: sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==}
|
||||
|
||||
'@modelcontextprotocol/sdk@1.30.0':
|
||||
resolution: {integrity: sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==}
|
||||
'@modelcontextprotocol/sdk@1.32.1':
|
||||
resolution: {integrity: sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
'@cfworker/json-schema': ^4.1.1
|
||||
@@ -5867,8 +5871,8 @@ packages:
|
||||
resolution: {integrity: sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
proxy-addr@2.0.7:
|
||||
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
|
||||
proxy-addr@2.0.8:
|
||||
resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
pump@3.0.4:
|
||||
@@ -7849,7 +7853,7 @@ snapshots:
|
||||
|
||||
'@levischuck/tiny-cbor@0.2.11': {}
|
||||
|
||||
'@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@7.2.0)(zod@3.25.76)':
|
||||
'@modelcontextprotocol/sdk@1.32.1(@cfworker/json-schema@4.1.1)(supports-color@7.2.0)(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@hono/node-server': 1.19.17(hono@4.13.7)
|
||||
ajv: 8.20.0
|
||||
@@ -7873,7 +7877,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@7.2.0)(zod@4.4.3)':
|
||||
'@modelcontextprotocol/sdk@1.32.1(@cfworker/json-schema@4.1.1)(supports-color@7.2.0)(zod@4.4.3)':
|
||||
dependencies:
|
||||
'@hono/node-server': 1.19.17(hono@4.13.7)
|
||||
ajv: 8.20.0
|
||||
@@ -11245,7 +11249,7 @@ snapshots:
|
||||
on-finished: 2.4.1
|
||||
once: 1.4.0
|
||||
parseurl: 1.3.3
|
||||
proxy-addr: 2.0.7
|
||||
proxy-addr: 2.0.8
|
||||
qs: 6.16.0
|
||||
range-parser: 1.3.0
|
||||
router: 2.2.0(supports-color@7.2.0)
|
||||
@@ -12741,7 +12745,7 @@ snapshots:
|
||||
'@types/node': 26.6.2
|
||||
long: 5.3.2
|
||||
|
||||
proxy-addr@2.0.7:
|
||||
proxy-addr@2.0.8:
|
||||
dependencies:
|
||||
forwarded: 0.2.0
|
||||
ipaddr.js: 1.9.1
|
||||
@@ -13195,7 +13199,7 @@ snapshots:
|
||||
'@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)
|
||||
'@babel/preset-typescript': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)
|
||||
'@dotenvx/dotenvx': 1.75.1
|
||||
'@modelcontextprotocol/sdk': 1.30.0(@cfworker/json-schema@4.1.1)(supports-color@7.2.0)(zod@3.25.76)
|
||||
'@modelcontextprotocol/sdk': 1.32.1(@cfworker/json-schema@4.1.1)(supports-color@7.2.0)(zod@3.25.76)
|
||||
'@types/validate-npm-package-name': 4.0.2
|
||||
browserslist: 4.28.7
|
||||
commander: 14.0.3
|
||||
|
||||
@@ -38,6 +38,10 @@ overrides:
|
||||
# follow-up fixed in 3.1.8, raised the floor from 3.1.6.
|
||||
"fast-uri@<3.1.8": "^3.1.8"
|
||||
"flatted@<3.4.2": "^3.4.2"
|
||||
# GHSA-jqcg-44mw-7w3h: IP spoofing through IPv4-mapped addresses. It
|
||||
# arrives under express in the MCP SDK; the app resolves client IPs itself,
|
||||
# but the copy sits on a production path.
|
||||
"proxy-addr@<2.0.8": "^2.0.8"
|
||||
# GHSA-3f6p-5ww8-9rcr: auth-plugin downgrade leaking plaintext credentials.
|
||||
# Reached only through prisma's MySQL driver, which this app never opens;
|
||||
# pinned so the audit reads clean, and mirrored for /opt/prisma-cli in the
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
-- The operator's reasoning controls on the instance row. Both columns carry a
|
||||
-- default, so the singleton row reads "reasoning allowed, no cap" the moment
|
||||
-- the migration lands and an upgrade needs no admin action.
|
||||
ALTER TABLE "app_settings"
|
||||
ADD COLUMN "ai_reasoning_enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
ADD COLUMN "ai_reasoning_max_effort" TEXT NOT NULL DEFAULT 'high';
|
||||
@@ -0,0 +1,4 @@
|
||||
-- The model-written text of a Coach turn's live trail, encrypted like the
|
||||
-- message itself. Nullable and without a default: every existing turn has no
|
||||
-- trail, and adding a nullable column rewrites no rows.
|
||||
ALTER TABLE "coach_messages" ADD COLUMN "trail_encrypted" BYTEA;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Where a Coach fact came from, when it was last used in a turn, and the
|
||||
-- message it came out of. Every row written before this migration came from
|
||||
-- the background extraction or the deterministic matcher, which could not be
|
||||
-- told apart, so they all read as `extracted`.
|
||||
ALTER TABLE "coach_facts"
|
||||
ADD COLUMN "source" TEXT NOT NULL DEFAULT 'extracted',
|
||||
ADD COLUMN "last_used_at" TIMESTAMP(3),
|
||||
ADD COLUMN "source_message_id" TEXT;
|
||||
@@ -4950,6 +4950,17 @@ model AppSettings {
|
||||
assistantInsightStatusEnabled Boolean @default(true) @map("assistant_insight_status_enabled")
|
||||
assistantDocumentAiEnabled Boolean @default(true) @map("assistant_document_ai_enabled")
|
||||
|
||||
// v1.41 — the operator's reasoning controls. `aiReasoningEnabled` switches
|
||||
// provider-side reasoning off for every surface (the Coach and the three
|
||||
// background jobs that use it); `aiReasoningMaxEffort` caps the level a
|
||||
// person may pick (`low` | `medium` | `high`, an app-side closed enum in
|
||||
// `src/lib/ai/reasoning/levels.ts`). Resolved once per turn or job by
|
||||
// `src/lib/ai/reasoning/resolve.ts` and published per record on
|
||||
// `GET /api/auth/me` as `coachReasoning`. Defaults keep reasoning on with no
|
||||
// cap, so an upgrade needs no admin action.
|
||||
aiReasoningEnabled Boolean @default(true) @map("ai_reasoning_enabled")
|
||||
aiReasoningMaxEffort String @default("high") @map("ai_reasoning_max_effort")
|
||||
|
||||
// v1.18.0 — operator-level server-wide module availability. A DISABLED
|
||||
// allowlist mirroring the per-user `User.modulePreferencesJson`: absent /
|
||||
// empty / `true` ⇒ available, an explicit `false` ⇒ disabled for EVERY
|
||||
@@ -6435,6 +6446,13 @@ model CoachMessage {
|
||||
// only the tables' metadata. Read through `readMessageResults` by the
|
||||
// owner-narrowed results endpoint; NULL on every turn without a table.
|
||||
resultsEncrypted Bytes? @map("results_encrypted")
|
||||
// v1.41 — the model-written text of the turn's live trail (JSON
|
||||
// `CoachTrail`, at most 16 KB): reasoning titles and texts per round, the
|
||||
// facts recalled into the turn, the text of a fact proposal. AES-256-GCM
|
||||
// like `encryptedContent`. The trail's plaintext metadata (phase, status,
|
||||
// round, catalog label, duration, count, stop reason) rides
|
||||
// `metricSourceJson.activity`. Owner-only; NULL on every turn without one.
|
||||
trailEncrypted Bytes? @map("trail_encrypted")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
conversation CoachConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
|
||||
@@ -6477,6 +6495,18 @@ model CoachFact {
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
// Soft-delete: hides the fact from injection but keeps the row for audit.
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
// v1.41 — who put the fact here, an app-side closed enum
|
||||
// (`COACH_FACT_SOURCES` in `src/lib/ai/coach/memory/contract.ts`): `user`
|
||||
// (the remember button, or a proposal the person confirmed), `coach` (saved
|
||||
// during a turn), `extracted` (the background extraction, and every row
|
||||
// written before the column existed), `pattern` (the deterministic matcher).
|
||||
source String @default("extracted")
|
||||
// v1.41 — when the fact last went into a Coach turn; NULL until it has.
|
||||
lastUsedAt DateTime? @map("last_used_at")
|
||||
// v1.41 — the message the fact came out of, for "undo" and "from this
|
||||
// conversation". A bare id like `sourceConversationId`, not a relation, so
|
||||
// deleting a conversation does not take the fact with it.
|
||||
sourceMessageId String? @map("source_message_id")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
|
||||
+1
-1
@@ -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.4";
|
||||
/* @sw-version-fallback */ "v1.41.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/*`
|
||||
|
||||
@@ -462,8 +462,8 @@ async function main() {
|
||||
}
|
||||
|
||||
// ───── Coach (Bytes columns) ─────
|
||||
// "encryptedContent" "resultsEncrypted" "summaryEncrypted" "titleEncrypted"
|
||||
// "factEncrypted"
|
||||
// "encryptedContent" "resultsEncrypted" "trailEncrypted" "summaryEncrypted"
|
||||
// "titleEncrypted" "factEncrypted"
|
||||
results.push(
|
||||
await rotateBytesColumn(
|
||||
"CoachMessage",
|
||||
@@ -480,6 +480,15 @@ async function main() {
|
||||
prisma.coachMessage,
|
||||
),
|
||||
);
|
||||
// v1.41 — the model text of the turn's trail. NULL on every turn without
|
||||
// one, which `rotateBytesColumn` skips.
|
||||
results.push(
|
||||
await rotateBytesColumn(
|
||||
"CoachMessage",
|
||||
"trailEncrypted",
|
||||
prisma.coachMessage,
|
||||
),
|
||||
);
|
||||
results.push(
|
||||
await rotateBytesColumn(
|
||||
"CoachConversation",
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* v1.41 — the operator's reasoning switch and cap cannot be bypassed.
|
||||
*
|
||||
* Two halves.
|
||||
*
|
||||
* Behaviour: `resolveReasoning` is the one decision. With the switch off it
|
||||
* answers `off` for the Coach and every background job, whatever the person
|
||||
* chose, whoever pays and whatever the provider can do; with it on, no answer
|
||||
* is above the cap. Proven exhaustively over the whole input space.
|
||||
*
|
||||
* Structure: a call carries reasoning only through `CompletionParams.reasoning`.
|
||||
* Every production file outside the provider clients that puts that key on a
|
||||
* call must take its value from the resolver (`@/lib/ai/reasoning/resolve` or
|
||||
* `@/lib/ai/reasoning/controls`). A file that hand-built `{ effort: "high" }`
|
||||
* would skip the switch, and goes red here.
|
||||
*
|
||||
* Its limit: the structural half reads source text for the setter shapes in
|
||||
* use (`reasoning: { effort`, `{ reasoning }`, `reasoning: completionReasoning(`,
|
||||
* `reasoning: resolveJobReasoning(`). A value laundered through another name
|
||||
* slips it; the resolver import is still required of the file that sets it.
|
||||
*
|
||||
* Mutation check: replace `resolveJobReasoning(...)` in `status-provider.ts`
|
||||
* with `{ effort: "high", summaries: false }` and drop the import: red.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
BACKGROUND_REASONING_JOBS,
|
||||
REASONING_LEVELS,
|
||||
REASONING_MAX_EFFORTS,
|
||||
} from "@/lib/ai/reasoning/levels";
|
||||
import {
|
||||
completionReasoning,
|
||||
resolveReasoning,
|
||||
type ReasoningSurface,
|
||||
} from "@/lib/ai/reasoning/resolve";
|
||||
|
||||
import { walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
const SURFACES: ReasoningSurface[] = ["coach", ...BACKGROUND_REASONING_JOBS];
|
||||
|
||||
describe("the switch and the cap win, behaviourally", () => {
|
||||
const supports = [
|
||||
null,
|
||||
{
|
||||
effort: true,
|
||||
summaries: true,
|
||||
liveSummaries: true,
|
||||
stateRoundTrip: true,
|
||||
offIsReal: false,
|
||||
},
|
||||
];
|
||||
|
||||
it("off reaches no surface as anything but off", () => {
|
||||
let cases = 0;
|
||||
for (const surface of SURFACES)
|
||||
for (const userPref of REASONING_LEVELS)
|
||||
for (const costOwner of ["user", "operator"] as const)
|
||||
for (const support of supports) {
|
||||
const r = resolveReasoning({
|
||||
surface,
|
||||
userPref,
|
||||
admin: { enabled: false, maxEffort: "high" },
|
||||
costOwner,
|
||||
support,
|
||||
});
|
||||
expect(r.effort).toBe("off");
|
||||
expect(r.source).toBe("admin_off");
|
||||
const sent = completionReasoning(r, surface);
|
||||
// A job sends nothing; the Coach sends an explicit off.
|
||||
if (surface === "coach") {
|
||||
expect(sent).toEqual({ effort: "off", summaries: false });
|
||||
} else {
|
||||
expect(sent).toBeUndefined();
|
||||
}
|
||||
cases++;
|
||||
}
|
||||
expect(cases).toBeGreaterThanOrEqual(100);
|
||||
});
|
||||
|
||||
it("no surface ever resolves above the cap", () => {
|
||||
for (const surface of SURFACES)
|
||||
for (const userPref of REASONING_LEVELS)
|
||||
for (const maxEffort of REASONING_MAX_EFFORTS) {
|
||||
const r = resolveReasoning({
|
||||
surface,
|
||||
userPref,
|
||||
admin: { enabled: true, maxEffort },
|
||||
costOwner: "user",
|
||||
});
|
||||
expect(REASONING_LEVELS.indexOf(r.effort)).toBeLessThanOrEqual(
|
||||
REASONING_LEVELS.indexOf(maxEffort),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/** The provider clients map an already-resolved value onto their wire. */
|
||||
const WIRE_LAYER = (rel: string) =>
|
||||
/^lib\/ai\/[a-z-]+-client\.ts$/.test(rel) ||
|
||||
rel === "lib/ai/provider-runner.ts" ||
|
||||
rel.startsWith("lib/ai/reasoning/");
|
||||
|
||||
const SETTERS = [
|
||||
/\breasoning\s*:\s*\{\s*effort\b/,
|
||||
/\{\s*reasoning\s*\}/,
|
||||
/\breasoning\s*:\s*(?:completionReasoning|resolveJobReasoning)\s*\(/,
|
||||
];
|
||||
|
||||
const RESOLVER_IMPORT =
|
||||
/from\s+["']@\/lib\/ai\/reasoning\/(?:resolve|controls)["']/;
|
||||
|
||||
function stripComments(source: string): string {
|
||||
return source
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.replace(/(^|[^:])\/\/.*$/gm, "$1");
|
||||
}
|
||||
|
||||
describe("every call that carries reasoning took it from the resolver", () => {
|
||||
const files = walkSourceFiles(SRC, { floor: 3000 })
|
||||
.filter((p) => !p.startsWith("generated/"))
|
||||
.filter((p) => !p.includes("__tests__"))
|
||||
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
|
||||
// Client components bind a form field called `reasoning`, not a call.
|
||||
.filter((p) => !p.startsWith("components/"))
|
||||
.filter((p) => !WIRE_LAYER(p));
|
||||
const setters = files.filter((rel) => {
|
||||
const code = stripComments(readFileSync(join(SRC, rel), "utf8"));
|
||||
return SETTERS.some((re) => re.test(code));
|
||||
});
|
||||
|
||||
it("finds the setters (an empty set is a failure, not a pass)", () => {
|
||||
expect(setters.length).toBeGreaterThanOrEqual(2);
|
||||
expect(setters).toContain("lib/insights/status-provider.ts");
|
||||
expect(setters).toContain("lib/insights/briefing-provider.ts");
|
||||
});
|
||||
|
||||
it("each one imports the resolver", () => {
|
||||
expect(
|
||||
setters.filter(
|
||||
(rel) => !RESOLVER_IMPORT.test(readFileSync(join(SRC, rel), "utf8")),
|
||||
),
|
||||
"A call carries reasoning that did not come from resolveReasoning, so the operator's switch and cap do not reach it.",
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The Coach turn receives its level on `TurnInput.reasoningLevel` and carries
|
||||
* it through the loop under its own names, which the setter shapes above do
|
||||
* not see. The one place that level is decided is the chat route, so the
|
||||
* route is pinned: it resolves the level, and it does not fall back to a
|
||||
* constant that would skip the operator's switch.
|
||||
*/
|
||||
describe("the Coach turn's level comes from the resolver", () => {
|
||||
const route = stripComments(
|
||||
readFileSync(join(SRC, "app/api/insights/chat/route.ts"), "utf8"),
|
||||
);
|
||||
|
||||
it("resolves the level in the chat route", () => {
|
||||
expect(route).toMatch(
|
||||
/reasoningLevel[^=]*=\s*await\s+resolveCoachTurnReasoningLevel\s*\(/,
|
||||
);
|
||||
expect(route).toMatch(RESOLVER_IMPORT);
|
||||
});
|
||||
|
||||
it("never hands the turn a constant level", () => {
|
||||
expect(route).not.toMatch(/\bDEFAULT_REASONING_LEVEL\b/);
|
||||
expect(route).not.toMatch(/reasoningLevel\s*:\s*["']/);
|
||||
});
|
||||
});
|
||||
@@ -3,11 +3,15 @@
|
||||
* the setting the resolver bound to the provider instance.
|
||||
*
|
||||
* Default must send nothing, so the key may not appear on any wire except as
|
||||
* the bound value, and only the two clients whose endpoint a person chose may
|
||||
* carry it. Pinned here:
|
||||
* the bound value or the call's own resolved level, and only the two clients
|
||||
* whose endpoint a person chose may carry it. Pinned here:
|
||||
*
|
||||
* 1. the key appears in exactly the Local client and the OpenAI client,
|
||||
* once each, and only as `reasoning_effort: this.reasoningEffort`;
|
||||
* twice each: once as `reasoning_effort: this.reasoningEffort`, sent
|
||||
* only when the call carries no level of its own (`!params.reasoning`),
|
||||
* and once as the call's level (v1.41 `CompletionParams.reasoning`,
|
||||
* resolved upstream against the person's choice and the operator's
|
||||
* cap), never as a literal;
|
||||
* 2. nothing but the binder assigns `.reasoningEffort`;
|
||||
* 3. every exported resolver in `provider.ts` calls `bindReasoningEffort`.
|
||||
*
|
||||
@@ -20,6 +24,7 @@
|
||||
* the behaviour on the wire.
|
||||
*
|
||||
* Mutation check: hard-code `reasoning_effort: "none"` into either client,
|
||||
* drop the `!params.reasoning` condition in front of the bound value,
|
||||
* set `client.reasoningEffort = ...` in a route, or drop `bindReasoningEffort`
|
||||
* from `resolveProviderForTest`: each one goes red here.
|
||||
*/
|
||||
@@ -60,13 +65,17 @@ describe("reasoning_effort on the wire", () => {
|
||||
});
|
||||
|
||||
it.each(["lib/ai/local-client.ts", "lib/ai/openai-client.ts"])(
|
||||
"%s sends it once, as the bound value",
|
||||
"%s sends the bound value only without a call level, and never a literal",
|
||||
(rel) => {
|
||||
const source = code(rel);
|
||||
expect(source.match(new RegExp(KEY))?.length).toBe(1);
|
||||
expect(source.match(new RegExp(KEY))?.length).toBe(2);
|
||||
expect(source).toMatch(
|
||||
/\breasoning_effort\s*:\s*this\s*\.\s*reasoningEffort\b/,
|
||||
);
|
||||
expect(source).toMatch(
|
||||
/!\s*params\s*\.\s*reasoning\s*&&\s*(?:this\s*\.\s*isGateway\s*&&\s*)?this\s*\.\s*reasoningEffort\b/,
|
||||
);
|
||||
expect(source).not.toMatch(/\breasoning_effort["']?\s*:\s*["'`]/);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -118,11 +118,17 @@ const BACKFILL_STAMPS: Record<string, string> = {
|
||||
|
||||
/**
|
||||
* `coachConversation` writes outside backfills that stamp `updatedAt`
|
||||
* because the person did something in the conversation, keyed by file.
|
||||
* because the person did something in the conversation, keyed by file, with
|
||||
* how many such writes the file holds. The count matters: keyed by file
|
||||
* alone, a new background write added beside the listed ones would pass
|
||||
* unseen.
|
||||
*/
|
||||
const COACH_ACTIVITY: Record<string, string> = {
|
||||
"src/lib/ai/coach/persistence.ts":
|
||||
"a turn sets updatedAt to now explicitly; a rename and a document attach are the person acting on the thread",
|
||||
const COACH_ACTIVITY: Record<string, { writes: number; reason: string }> = {
|
||||
"src/lib/ai/coach/persistence.ts": {
|
||||
writes: 2,
|
||||
reason:
|
||||
"a rename and a document attach are the person acting on the thread (a turn names updatedAt explicitly)",
|
||||
},
|
||||
};
|
||||
|
||||
describe("backfills keep updatedAt", () => {
|
||||
@@ -180,13 +186,21 @@ describe("coach conversations move only on activity", () => {
|
||||
});
|
||||
|
||||
it("names updatedAt in every write that is not the person's own action", () => {
|
||||
const offenders = writes
|
||||
.filter((w) => !w.namesUpdatedAt && !(w.file in COACH_ACTIVITY))
|
||||
.map((w) => w.file);
|
||||
const stamping = new Map<string, number>();
|
||||
for (const w of writes.filter((w) => !w.namesUpdatedAt)) {
|
||||
stamping.set(w.file, (stamping.get(w.file) ?? 0) + 1);
|
||||
}
|
||||
const offenders = [...stamping]
|
||||
.filter(([file, n]) => n !== COACH_ACTIVITY[file]?.writes)
|
||||
.map(([file, n]) => `${file}: ${n} stamping write(s)`);
|
||||
expect(
|
||||
[...new Set(offenders)],
|
||||
"a coachConversation write that is not activity must keep updatedAt (carry it, or write through raw SQL)",
|
||||
offenders,
|
||||
"a coachConversation write that is not activity must keep updatedAt (carry it, or write through raw SQL); a new activity write is listed with its count",
|
||||
).toEqual([]);
|
||||
// Every listed file is still there with its writes.
|
||||
expect(Object.keys(COACH_ACTIVITY).filter((f) => !stamping.has(f))).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* v1.41 — which background calls may reason, frozen.
|
||||
*
|
||||
* Reasoning in the background is paid for every night, for every account, by
|
||||
* nobody watching. So it is admitted where it changes the result a person
|
||||
* reads (the daily briefing and the period narratives) and nowhere else: not
|
||||
* on status cards, batch notes, derived scores, workout and reaction lines,
|
||||
* nudges, document reads or OCR, which are routine, latency-bound and
|
||||
* seed-reproducible.
|
||||
*
|
||||
* The only way a background call asks is the `reasoningJob` argument of the
|
||||
* two chokepoints (`runStatusCompletion`, `runBriefingCompletion`). This test
|
||||
* finds every place that sets it and holds the set, file by file and job by
|
||||
* job, against the inventory below. A status card that started passing a job
|
||||
* goes red here; so does a new caller nobody admitted.
|
||||
*
|
||||
* Its limit: it reads source text for a `reasoningJob:` property and the job
|
||||
* literals beside it. A call that built the argument object elsewhere and
|
||||
* spread it in would slip the matcher; the chokepoint signature is the only
|
||||
* documented way in, and the wire test (`background-reasoning-wire.test.ts`)
|
||||
* proves a call without the argument sends nothing.
|
||||
*
|
||||
* Mutation check: add `reasoningJob: "daily_briefing"` to
|
||||
* `src/lib/insights/status-batch.ts`, or delete it from the narrative
|
||||
* generator: either goes red.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { BACKGROUND_REASONING_JOBS } from "@/lib/ai/reasoning/levels";
|
||||
|
||||
import { walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
/** File → the jobs it may name, each with the reason it may. */
|
||||
const ADMITTED: Record<string, { jobs: string[]; why: string }> = {
|
||||
"lib/insights/comprehensive-generate.ts": {
|
||||
jobs: ["daily_briefing"],
|
||||
why: "The daily briefing's generation and its phrasing re-roll; the JSON and grounding repairs do not reason.",
|
||||
},
|
||||
"app/api/insights/generate/route.ts": {
|
||||
jobs: ["daily_briefing"],
|
||||
why: "A briefing asked for by hand reasons like the scheduled one; its JSON and grounding repairs do not.",
|
||||
},
|
||||
"lib/insights/narrative/period-narrative-generate.ts": {
|
||||
jobs: ["period_narrative_month", "period_narrative_week"],
|
||||
why: "The weekly and monthly narratives' first pass; the grounding repair does not reason.",
|
||||
},
|
||||
};
|
||||
|
||||
/** The routine generators that must never name a job. */
|
||||
const NEVER = [
|
||||
"lib/insights/status-batch.ts",
|
||||
"lib/insights/status-card-generation.ts",
|
||||
"lib/insights/metric-status.ts",
|
||||
"lib/insights/biomarker-status.ts",
|
||||
"lib/insights/derived/derived-assessment-ai.ts",
|
||||
"lib/jobs/workout-insight-generate.ts",
|
||||
];
|
||||
|
||||
function stripComments(source: string): string {
|
||||
return source
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.replace(/(^|[^:])\/\/.*$/gm, "$1");
|
||||
}
|
||||
|
||||
/** `reasoningJob:` as a value, not the optional declaration `reasoningJob?:`. */
|
||||
const SETTER = /\breasoningJob\s*:(?!\s*BackgroundReasoningJob)/g;
|
||||
|
||||
function scan(): Map<string, { setters: number; jobs: Set<string> }> {
|
||||
const found = new Map<string, { setters: number; jobs: Set<string> }>();
|
||||
const files = walkSourceFiles(SRC, { floor: 3000 })
|
||||
.filter((p) => !p.startsWith("generated/"))
|
||||
.filter((p) => !p.includes("__tests__"))
|
||||
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"));
|
||||
for (const rel of files) {
|
||||
const code = stripComments(readFileSync(join(SRC, rel), "utf8"));
|
||||
const setters = code.match(SETTER)?.length ?? 0;
|
||||
if (setters === 0) continue;
|
||||
const jobs = new Set<string>();
|
||||
for (const m of code.matchAll(SETTER)) {
|
||||
const window = code.slice(m.index, m.index + 160);
|
||||
for (const job of BACKGROUND_REASONING_JOBS) {
|
||||
if (window.includes(`"${job}"`)) jobs.add(job);
|
||||
}
|
||||
}
|
||||
found.set(rel, { setters, jobs });
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
describe("background reasoning inventory", () => {
|
||||
const found = scan();
|
||||
|
||||
it("finds the admitted callers (an empty set is a failure, not a pass)", () => {
|
||||
expect(found.size).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("only the admitted files name a job", () => {
|
||||
expect([...found.keys()].sort()).toEqual(Object.keys(ADMITTED).sort());
|
||||
});
|
||||
|
||||
it.each(Object.entries(ADMITTED))(
|
||||
"%s names exactly its admitted jobs",
|
||||
(rel, { jobs }) => {
|
||||
expect([...(found.get(rel)?.jobs ?? [])].sort()).toEqual(
|
||||
[...jobs].sort(),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(NEVER)("%s never reasons", (rel) => {
|
||||
const code = stripComments(readFileSync(join(SRC, rel), "utf8"));
|
||||
expect(code).not.toMatch(/\breasoningJob\b/);
|
||||
expect(code).not.toMatch(/\breasoning\s*:/);
|
||||
});
|
||||
|
||||
it("every admitted job is a known background job", () => {
|
||||
for (const { jobs } of Object.values(ADMITTED)) {
|
||||
for (const job of jobs) {
|
||||
expect(BACKGROUND_REASONING_JOBS as readonly string[]).toContain(job);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Structural guard on keyboard focus in the Coach panel.
|
||||
*
|
||||
* The purple `--ring` is gone from every Coach control: a tap draws nothing,
|
||||
* keyboard focus draws a solid ring in the neutral text-field focus tone
|
||||
* (`--input-focus`). Two checks:
|
||||
*
|
||||
* 1. No class string under `coach-panel/` names the purple ring, border or
|
||||
* outline on focus. The sweep must find a floor of neutral focus rings,
|
||||
* so a matcher that stops matching fails instead of passing on nothing.
|
||||
* 2. The controls built on the `Button` primitive inside an answer (the
|
||||
* reply pills, Undo, Change) pass `COACH_FOCUS_RING`, which replaces the
|
||||
* primitive's purple ring when the classes merge.
|
||||
*/
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const DIR = join(__dirname, "..", "components", "insights", "coach-panel");
|
||||
|
||||
const SOURCES = readdirSync(DIR)
|
||||
.filter((name) => /\.(tsx|ts)$/.test(name))
|
||||
.map((name) => ({ name, text: readFileSync(join(DIR, name), "utf8") }));
|
||||
|
||||
const PURPLE_FOCUS =
|
||||
/(?:focus|focus-visible|focus-within):(?:ring-ring|border-ring|outline-ring|ring-primary|border-primary)\b/g;
|
||||
|
||||
describe("Coach focus ring", () => {
|
||||
it("never names the purple ring on focus", () => {
|
||||
const offenders = SOURCES.flatMap(({ name, text }) =>
|
||||
[...text.matchAll(PURPLE_FOCUS)].map((m) => `${name}: ${m[0]}`),
|
||||
);
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("finds the neutral ring where it expects it", () => {
|
||||
const neutral = SOURCES.reduce(
|
||||
(n, { text }) =>
|
||||
n + (text.match(/focus-visible:ring-input-focus\b/g)?.length ?? 0),
|
||||
0,
|
||||
);
|
||||
expect(neutral).toBeGreaterThanOrEqual(15);
|
||||
});
|
||||
|
||||
it.each(["suggested-replies.tsx", "memory-note.tsx", "assumption-line.tsx"])(
|
||||
"%s merges the neutral ring over the Button primitive",
|
||||
(file) => {
|
||||
const text = SOURCES.find((s) => s.name === file)?.text ?? "";
|
||||
expect(text).toMatch(
|
||||
/import \{ COACH_FOCUS_RING \} from "\.\/focus-ring"/,
|
||||
);
|
||||
expect(text).toMatch(/cn\([\s\S]*?COACH_FOCUS_RING,?\s*\)/);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -10,7 +10,8 @@
|
||||
* shape would make the whole frame fail to decode, and a new frame that
|
||||
* reused one with the same shape would leak a value into whatever the client
|
||||
* does with that key. So a new frame names its payload with a key of its own
|
||||
* (`step`, `result`, `followUps`, `clarification`).
|
||||
* (`step`, `result`, `followUps`, `clarification`, and since v1.41
|
||||
* `activity`, `memoryNote` with `note`, `planProposal` with `proposal`).
|
||||
*
|
||||
* Reads the frame list from the Zod mirror (`stream-events.ts`), which a
|
||||
* type test holds equal to `CoachStreamEvent`, so a frame added to the
|
||||
@@ -81,6 +82,9 @@ describe("Coach stream frames stay decodable by the shipped native client", () =
|
||||
"result",
|
||||
"followUps",
|
||||
"clarification",
|
||||
"activity",
|
||||
"memoryNote",
|
||||
"planProposal",
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* v1.41 — the Coach's voice, held in the prompt.
|
||||
*
|
||||
* The voice section (`buildCoachVoiceBlock`) is the one statement of how a
|
||||
* Coach answer is shaped: the answer first, one carrying number, the
|
||||
* assumption in one line, at most one next step, 40-120 words, and a closed
|
||||
* list of filler it never writes. A prompt rewrite that drops a rule, or an
|
||||
* example that models the opposite (a closing question, two next steps, a
|
||||
* filler), would quietly teach the model the old habits back. This guard
|
||||
* pins both bodies to the rules, and every few-shot example to the grader
|
||||
* that scores live answers.
|
||||
*
|
||||
* Each check is a function run once on the real prompt and once on a prompt
|
||||
* broken on purpose, so the guard is shown to fail.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
COACH_ANSWER_WORDS,
|
||||
COACH_FILLER_PHRASES,
|
||||
buildCoachVoiceBlock,
|
||||
getCoachSystemPrompt,
|
||||
} from "@/lib/ai/coach/system-prompt";
|
||||
import { gradeVoice } from "@/lib/ai/coach/eval/grade-voice";
|
||||
import { DEFAULT_COACH_PREFS } from "@/lib/validations/coach-prefs";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
|
||||
const LOCALES: Locale[] = ["de", "en", "fr", "es", "it", "pl", "ko"];
|
||||
|
||||
/** The rules a voice section must state; `[]` when it states them all. */
|
||||
function missingVoiceRules(prompt: string, lang: "de" | "en"): string[] {
|
||||
const missing: string[] = [];
|
||||
const band = COACH_ANSWER_WORDS.standard;
|
||||
const header =
|
||||
lang === "de" ? "TON UND ANTWORTAUFBAU" : "VOICE AND ANSWER SHAPE";
|
||||
if (!prompt.includes(header)) missing.push("header");
|
||||
if (!prompt.includes(`${band.min}-${band.max}`)) missing.push("length band");
|
||||
const rules =
|
||||
lang === "de"
|
||||
? [
|
||||
/erste Satz beantwortet die Frage/,
|
||||
/Eine tragende Zahl/,
|
||||
/Angenommen: letzte/,
|
||||
/höchstens EINEM konkreten nächsten Schritt/,
|
||||
/Nie eine Frage am Ende/,
|
||||
/keine Emojis/,
|
||||
]
|
||||
: [
|
||||
/first sentence answers the question/,
|
||||
/One carrying number/,
|
||||
/Assumed: last 30 days/,
|
||||
/at most ONE concrete next step/,
|
||||
/Never a question at the end/,
|
||||
/no emojis/,
|
||||
];
|
||||
for (const rule of rules) if (!rule.test(prompt)) missing.push(rule.source);
|
||||
for (const phrase of COACH_FILLER_PHRASES[lang]) {
|
||||
if (!prompt.includes(`"${phrase}"`)) missing.push(`filler ${phrase}`);
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
/** The COACH lines of the few-shot examples. */
|
||||
function exampleAnswers(prompt: string): string[] {
|
||||
const answers: string[] = [];
|
||||
for (const match of prompt.matchAll(/<example>([\s\S]*?)<\/example>/g)) {
|
||||
const coach = match[1].split(/^COACH:\s*/m)[1];
|
||||
if (coach) answers.push(coach.trim());
|
||||
}
|
||||
return answers;
|
||||
}
|
||||
|
||||
/** Examples that break the voice; `[]` when every one keeps it. */
|
||||
function examplesOffVoice(prompt: string): string[] {
|
||||
return exampleAnswers(prompt).filter((answer) => !gradeVoice(answer).passed);
|
||||
}
|
||||
|
||||
describe("coach voice guard", () => {
|
||||
it.each(["en", "de"] as const)(
|
||||
"the %s body states every voice rule and filler",
|
||||
(lang) => {
|
||||
const prompt = getCoachSystemPrompt(lang);
|
||||
expect(missingVoiceRules(prompt, lang)).toEqual([]);
|
||||
// Broken on purpose: one filler dropped from the list.
|
||||
const broken = prompt.replaceAll(
|
||||
`"${COACH_FILLER_PHRASES[lang][0]}"`,
|
||||
"",
|
||||
);
|
||||
expect(missingVoiceRules(broken, lang)).not.toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(LOCALES)("the %s prompt carries the voice section", (locale) => {
|
||||
expect(getCoachSystemPrompt(locale)).toContain(
|
||||
buildCoachVoiceBlock(locale),
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["en", "de"] as const)(
|
||||
"every %s example answer keeps the voice",
|
||||
(lang) => {
|
||||
const prompt = getCoachSystemPrompt(lang);
|
||||
expect(exampleAnswers(prompt).length).toBeGreaterThanOrEqual(5);
|
||||
expect(examplesOffVoice(prompt)).toEqual([]);
|
||||
// Broken on purpose: an example that ends on a habitual question.
|
||||
const broken = prompt.replace(
|
||||
/(<example>[\s\S]*?COACH:[\s\S]*?)(\n(?:---KEYVALUES---|<\/example>))/,
|
||||
"$1 How does that sound to you?$2",
|
||||
);
|
||||
expect(examplesOffVoice(broken)).not.toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps no length rule from before the voice section", () => {
|
||||
for (const lang of ["en", "de"] as const) {
|
||||
const prompt = getCoachSystemPrompt(lang, {
|
||||
...DEFAULT_COACH_PREFS,
|
||||
verbosity: "detailed",
|
||||
});
|
||||
expect(prompt).not.toMatch(/60-180/);
|
||||
expect(prompt).not.toMatch(/180-250/);
|
||||
expect(prompt).not.toMatch(/~90/);
|
||||
}
|
||||
});
|
||||
|
||||
it("states the brief and detailed lengths from the same numbers", () => {
|
||||
const brief = getCoachSystemPrompt("en", {
|
||||
...DEFAULT_COACH_PREFS,
|
||||
verbosity: "brief",
|
||||
});
|
||||
expect(brief).toContain(
|
||||
`${COACH_ANSWER_WORDS.brief.min}-${COACH_ANSWER_WORDS.brief.max} words`,
|
||||
);
|
||||
const detailed = getCoachSystemPrompt("de", {
|
||||
...DEFAULT_COACH_PREFS,
|
||||
verbosity: "detailed",
|
||||
});
|
||||
expect(detailed).toContain(`bis ${COACH_ANSWER_WORDS.detailed.max} Wörter`);
|
||||
});
|
||||
|
||||
it("asks a confidence question only on an explicit plan request", () => {
|
||||
expect(getCoachSystemPrompt("en")).toMatch(
|
||||
/Confidence ruler, only on an explicit plan request/,
|
||||
);
|
||||
expect(getCoachSystemPrompt("de")).toMatch(
|
||||
/Konfidenz-Skala, nur bei ausdrücklicher Plan-Bitte/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -196,9 +196,26 @@ const REGISTRY: DestructiveEntry[] = [
|
||||
},
|
||||
{
|
||||
file: "components/settings/coach-memory-section.tsx",
|
||||
destroys: "one remembered Coach fact, or every one of them",
|
||||
destroys: "every remembered Coach fact at once",
|
||||
recovery: "tombstoned-no-restore",
|
||||
confirm: ["ConfirmButton", "AlertDialog"],
|
||||
confirm: ["AlertDialog"],
|
||||
},
|
||||
{
|
||||
file: "components/insights/coach-panel/use-coach-facts.ts",
|
||||
destroys: "one remembered Coach fact",
|
||||
triggers: [
|
||||
"components/settings/coach-memory-section.tsx",
|
||||
"components/insights/coach-panel/memory-note.tsx",
|
||||
],
|
||||
recovery: "tombstoned-no-restore",
|
||||
confirm: ["ConfirmButton"],
|
||||
unconfirmed: [
|
||||
{
|
||||
control: "Undo on the memory note under an answer — memory-note.tsx",
|
||||
reason:
|
||||
"Undo takes back what the Coach saved a moment ago on its own, from the person's own words in the same turn. Nothing the person wrote is lost: their message stays in the thread and the fact can be kept again with one tap. Asking to confirm an undo would make reversing the Coach dearer than letting it keep something unwanted. Forgetting from the memory list, where a fact may be old and relied on, is confirmed.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "hooks/use-coach-reminders.ts",
|
||||
@@ -210,16 +227,12 @@ const REGISTRY: DestructiveEntry[] = [
|
||||
{
|
||||
file: "hooks/use-coach-plans.ts",
|
||||
destroys: "a Coach plan and the record of how it went",
|
||||
triggers: [
|
||||
"app/coach/plans/page.tsx",
|
||||
"components/insights/coach-panel/plan-proposal-card.tsx",
|
||||
],
|
||||
triggers: ["app/coach/plans/page.tsx"],
|
||||
recovery: "tombstoned-no-restore",
|
||||
confirm: ["ConfirmButton"],
|
||||
unconfirmed: [
|
||||
{
|
||||
control:
|
||||
"decline a PROPOSED plan — app/coach/plans/page.tsx and plan-proposal-card.tsx",
|
||||
control: "decline a PROPOSED plan — app/coach/plans/page.tsx",
|
||||
reason:
|
||||
"Declining is the negative half of an accept/decline pair the assistant just offered; the person has invested nothing in a proposal they never adopted, and the same suggestion re-derives on a later turn. Confirming a refusal would make saying no more expensive than saying yes. The delete of a plan the person actually RAN is confirmed, and that is the one carrying history.",
|
||||
},
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* v1.41 — what the person told the Coach leaves the machine only when the
|
||||
* turn may send to its providers.
|
||||
*
|
||||
* The memory block (`WHAT YOU KNOW ABOUT THIS PERSON`) carries facts, plans
|
||||
* and reminders the person wrote; the briefing's plan lines carry plan
|
||||
* targets. Both are built, not built-then-dropped: the egress or capability
|
||||
* check runs first and nothing is read on a refusal. Behaviour is pinned in
|
||||
* `memory/__tests__/context-block.test.ts` and `plan-progress.test.ts`; this
|
||||
* guard pins the structure those tests rest on:
|
||||
*
|
||||
* 1. Inside each builder, the check comes before the first database read.
|
||||
* 2. The builders are called only from the files that run after the turn's
|
||||
* own egress check (the Coach turn) or behind the briefing capability (the
|
||||
* briefing generation), and a Coach-turn caller hands over the chain so
|
||||
* the wire check runs for exactly those providers.
|
||||
* 3. No `annotate()` in the memory modules names a text-bearing value.
|
||||
*
|
||||
* Every check is also run against a source broken on purpose.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
const MEMORY = "lib/ai/coach/memory";
|
||||
|
||||
function read(rel: string): string {
|
||||
return readFileSync(join(SRC, rel), "utf8");
|
||||
}
|
||||
|
||||
function sourceFiles(): string[] {
|
||||
return walkSourceFiles(SRC, { floor: 3000 })
|
||||
.filter((p) => !p.startsWith("generated/"))
|
||||
.filter((p) => !p.includes("__tests__"))
|
||||
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"));
|
||||
}
|
||||
|
||||
/** The body of `export async function <name>(`, up to the next top-level `}`. */
|
||||
function functionBody(source: string, name: string): string {
|
||||
const start = source.search(
|
||||
new RegExp(`export\\s+async\\s+function\\s+${name}\\s*\\(`),
|
||||
);
|
||||
if (start === -1) return "";
|
||||
const end = source.indexOf("\n}\n", start);
|
||||
return source.slice(start, end === -1 ? undefined : end);
|
||||
}
|
||||
|
||||
/** Whether `check` appears before the first database read in `body`. */
|
||||
function checksBeforeRead(body: string, check: RegExp): boolean {
|
||||
const checkAt = body.search(check);
|
||||
const readAt = body.search(
|
||||
/\bprisma\.|\bload(?:Facts|Plans|Reminders)\(|takePendingProposal\(/,
|
||||
);
|
||||
return checkAt !== -1 && readAt !== -1 && checkAt < readAt;
|
||||
}
|
||||
|
||||
describe("memory block egress guard", () => {
|
||||
const block = functionBody(
|
||||
read(`${MEMORY}/context-block.ts`),
|
||||
"buildMemoryContextBlock",
|
||||
);
|
||||
const lines = functionBody(
|
||||
read(`${MEMORY}/plan-progress.ts`),
|
||||
"buildPlanProgressLines",
|
||||
);
|
||||
|
||||
it("the memory block checks egress before it reads anything", () => {
|
||||
expect(block.length).toBeGreaterThan(200);
|
||||
expect(checksBeforeRead(block, /egressAllowed\(/)).toBe(true);
|
||||
const helper = read(`${MEMORY}/context-block.ts`);
|
||||
expect(helper).toMatch(/aiEgressRefusal\(\s*"coach"/);
|
||||
expect(helper).toMatch(/aiCapabilityForRecord\(args\.userId, "coach"\)/);
|
||||
// Broken on purpose: the check moved below the reads.
|
||||
const broken = block.replace(/if \(!\(await egressAllowed\(args\)\)\)/, "");
|
||||
expect(checksBeforeRead(broken, /egressAllowed\(/)).toBe(false);
|
||||
});
|
||||
|
||||
it("the briefing's plan lines check the briefing capability before any read", () => {
|
||||
expect(lines.length).toBeGreaterThan(200);
|
||||
expect(
|
||||
checksBeforeRead(lines, /aiCapabilityForRecord\(userId, "briefing"\)/),
|
||||
).toBe(true);
|
||||
const broken = lines.replace(
|
||||
/aiCapabilityForRecord\(userId, "briefing"\)/,
|
||||
"",
|
||||
);
|
||||
expect(
|
||||
checksBeforeRead(broken, /aiCapabilityForRecord\(userId, "briefing"\)/),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* Files that may build the memory block: the Coach turn, after its own
|
||||
* capability and consent checks (`turn/pipeline.ts` rechecks before it
|
||||
* runs the model). The contract re-exports it; nothing else calls it.
|
||||
*/
|
||||
const BLOCK_CALLERS = new Set([
|
||||
"lib/ai/coach/turn/model.ts",
|
||||
"lib/ai/coach/turn/context.ts",
|
||||
]);
|
||||
/** Files that may build the briefing's plan lines. */
|
||||
const LINE_CALLERS = new Set([
|
||||
"lib/insights/comprehensive-generate.ts",
|
||||
"lib/insights/briefing-provider.ts",
|
||||
]);
|
||||
|
||||
function callers(name: string): string[] {
|
||||
const call = new RegExp(`\\b${name}\\(`);
|
||||
return sourceFiles()
|
||||
.filter((rel) => !rel.startsWith(`${MEMORY}/`))
|
||||
.filter((rel) => call.test(read(rel)));
|
||||
}
|
||||
|
||||
it("only the Coach turn builds the memory block, and it names its providers", () => {
|
||||
const found = callers("buildMemoryContextBlock");
|
||||
for (const rel of found) {
|
||||
expect(BLOCK_CALLERS.has(rel), rel).toBe(true);
|
||||
expect(read(rel), `${rel} hands over the chain`).toMatch(
|
||||
/buildMemoryContextBlock\(\{[\s\S]{0,400}providerTypes/,
|
||||
);
|
||||
}
|
||||
// The matcher is live: it finds the definition it guards.
|
||||
const definitions = sourceFiles().filter((rel) =>
|
||||
/export async function buildMemoryContextBlock\(/.test(read(rel)),
|
||||
);
|
||||
expect(definitions).toEqual([`${MEMORY}/context-block.ts`]);
|
||||
});
|
||||
|
||||
it("only the briefing builds the plan lines", () => {
|
||||
for (const rel of callers("buildPlanProgressLines")) {
|
||||
expect(LINE_CALLERS.has(rel), rel).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("no memory annotation names a text-bearing value", () => {
|
||||
const TEXT =
|
||||
/\b(?:fact|text|ifCue|thenAction|target|recalled|note\.fact|userMessage|content)\s*[,:}]/;
|
||||
const offending: string[] = [];
|
||||
const files = walkSourceFiles(join(SRC, MEMORY), { floor: 5 }).filter(
|
||||
(p) => !p.includes("__tests__"),
|
||||
);
|
||||
for (const rel of files) {
|
||||
const source = read(`${MEMORY}/${rel}`);
|
||||
for (const match of source.matchAll(/annotate\(\{[\s\S]*?\}\);/g)) {
|
||||
const meta = match[0].split("meta:")[1] ?? "";
|
||||
if (TEXT.test(meta)) offending.push(`${rel}: ${match[0]}`);
|
||||
}
|
||||
}
|
||||
expect(offending).toEqual([]);
|
||||
// Broken on purpose: a meta that carries the fact.
|
||||
const planted = `annotate({ action: { name: "x" }, meta: { fact: call.fact } });`;
|
||||
expect(TEXT.test(planted.split("meta:")[1])).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -159,6 +159,18 @@ describe("proxy.ts DEMO_MODE mutation allowlist", () => {
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("refuses the Coach chat with a code a client can name", async () => {
|
||||
// The chat POST is not on the allowlist. The refusal carries a stable
|
||||
// code, so the Coach says "not in the demo" instead of reading the 403
|
||||
// as a provider outage.
|
||||
const res = proxy(makeRequest("/api/insights/chat", "POST"));
|
||||
expect(res.status).toBe(403);
|
||||
const body = (await res.json()) as {
|
||||
meta?: { demo?: boolean; errorCode?: string };
|
||||
};
|
||||
expect(body.meta).toEqual({ demo: true, errorCode: "demo.readOnly" });
|
||||
});
|
||||
|
||||
it("does not drag in the layout-reset DELETE on the allowlisted widgets path", () => {
|
||||
// The allowlist pins method per path; admitting the widgets PUT
|
||||
// must not open DELETE (which wipes the user's dashboard layout).
|
||||
|
||||
@@ -190,9 +190,6 @@ const PINNED_AFFORDANCES: Record<
|
||||
"src/components/insights/coach-panel/history-rail.tsx": {
|
||||
"toast.success": 1,
|
||||
},
|
||||
"src/components/insights/coach-panel/plan-proposal-card.tsx": {
|
||||
"text-success": 1,
|
||||
},
|
||||
"src/components/insights/coach-panel/reminder-suggestion-card.tsx": {
|
||||
"text-success": 1,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Structural guard on the focus treatment of text-entry fields.
|
||||
*
|
||||
* A text field shows focus by its border alone: `border-input` turns the
|
||||
* neutral `input-focus` tone (a foreground-based grey, at least 3:1 on the
|
||||
* background in both themes). No colour ring, no purple. The ring was the
|
||||
* loudest thing on a form, and for a text field it is on for every mouse
|
||||
* click too, because `:focus-visible` always matches a field that takes
|
||||
* typed input. Buttons, links, checkboxes and switches keep their rings;
|
||||
* they are not in scope here.
|
||||
*
|
||||
* The one ring a field still draws is the destructive one on an invalid
|
||||
* field that has focus (`aria-invalid:focus-visible:ring-[3px]`): its border
|
||||
* is already red, so the ring is what tells focus apart there.
|
||||
*
|
||||
* Two checks:
|
||||
*
|
||||
* 1. Every text-entry primitive (and the Coach composer) uses the token.
|
||||
* 2. Every class string in `src/` that styles a text field (it names
|
||||
* `border-input`, the focus token, or a `placeholder:` colour) carries no focus ring and no
|
||||
* primary/ring-coloured focus border. The sweep must find a floor of
|
||||
* such strings, so a matcher that stops matching fails instead of
|
||||
* passing on nothing.
|
||||
*/
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join, relative } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const ROOT = join(__dirname, "..", "..");
|
||||
const SRC = join(ROOT, "src");
|
||||
|
||||
/** Text-entry primitives and the hand-built text fields that own a focus style. */
|
||||
const TEXT_FIELD_FILES = [
|
||||
"src/components/ui/input.tsx",
|
||||
"src/components/ui/textarea.tsx",
|
||||
"src/components/ui/select.tsx",
|
||||
"src/components/ui/native-select.tsx",
|
||||
"src/components/ui/date-field.tsx",
|
||||
"src/components/ui/time-field.tsx",
|
||||
"src/components/ui/calendar.tsx",
|
||||
"src/components/insights/coach-panel/coach-input.tsx",
|
||||
"src/components/cycle/cycle-settings.tsx",
|
||||
"src/components/records/about-me-note-manager.tsx",
|
||||
"src/components/records/conditions-manager.tsx",
|
||||
"src/components/records/allergy-free-text-note.tsx",
|
||||
];
|
||||
|
||||
const FOCUS_VARIANT = /(^|:)(focus|focus-visible|focus-within|has-focus)(:|$)/;
|
||||
const BANNED_UTILITY =
|
||||
/^(ring(-\[[^\]]+\]|-\d+)?$|ring-(ring|primary|offset)|border-(ring|primary)|outline-(ring|primary))/;
|
||||
|
||||
/** Focus tokens in a class string that put a ring or a colour border on focus. */
|
||||
function bannedFocusTokens(classString: string): string[] {
|
||||
return classString.split(/\s+/).filter((token) => {
|
||||
if (!token.includes(":")) return false;
|
||||
const parts = token.split(":");
|
||||
const utility = parts.pop()!;
|
||||
const variants = parts.join(":");
|
||||
if (!FOCUS_VARIANT.test(variants)) return false;
|
||||
// The invalid field's destructive focus ring is the documented exception.
|
||||
if (/(^|:)aria-invalid(:|$)/.test(variants)) return false;
|
||||
return BANNED_UTILITY.test(utility);
|
||||
});
|
||||
}
|
||||
|
||||
function walk(dir: string, out: string[] = []): string[] {
|
||||
for (const name of readdirSync(dir)) {
|
||||
const path = join(dir, name);
|
||||
if (name === "generated" || name === "__tests__") continue;
|
||||
if (statSync(path).isDirectory()) walk(path, out);
|
||||
else if (/\.(ts|tsx)$/.test(name) && !/\.test\.tsx?$/.test(name))
|
||||
out.push(path);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const STRING_LITERAL = /"([^"\n]*)"|`([^`]*)`|'([^'\n]*)'/g;
|
||||
const TEXT_FIELD_MARKER =
|
||||
/(^|\s)(border-input|placeholder:text-)|border-input-focus/;
|
||||
|
||||
describe("text-field focus", () => {
|
||||
it("the detector flags a ring and a purple border, and spares the exceptions", () => {
|
||||
expect(
|
||||
bannedFocusTokens(
|
||||
"focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-within:border-primary/50 focus-within:ring-2 has-focus:border-ring focus-visible:ring-offset-2",
|
||||
),
|
||||
).toHaveLength(6);
|
||||
expect(
|
||||
bannedFocusTokens(
|
||||
"focus-visible:border-input-focus aria-invalid:focus-visible:ring-[3px] focus-visible:outline-none aria-invalid:ring-destructive/20",
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(TEXT_FIELD_FILES)("%s uses the neutral focus border", (file) => {
|
||||
const source = readFileSync(join(ROOT, file), "utf8");
|
||||
expect(source).toContain("border-input-focus");
|
||||
expect(source).not.toMatch(/ring-primary/);
|
||||
});
|
||||
|
||||
it("no class string that styles a text field draws a focus ring", () => {
|
||||
const offenders: string[] = [];
|
||||
let fieldStrings = 0;
|
||||
for (const file of walk(SRC)) {
|
||||
const source = readFileSync(file, "utf8");
|
||||
for (const match of source.matchAll(STRING_LITERAL)) {
|
||||
const literal = match[1] ?? match[2] ?? match[3] ?? "";
|
||||
if (!TEXT_FIELD_MARKER.test(literal)) continue;
|
||||
fieldStrings++;
|
||||
const bad = bannedFocusTokens(literal);
|
||||
if (bad.length > 0)
|
||||
offenders.push(`${relative(ROOT, file)}: ${bad.join(" ")}`);
|
||||
}
|
||||
}
|
||||
// The primitives alone account for more than this; fewer means the
|
||||
// matcher went blind, not that the fields went away.
|
||||
expect(fieldStrings).toBeGreaterThanOrEqual(10);
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -390,7 +390,7 @@ const UNIT_LITERAL_ALLOWLIST: Record<
|
||||
"the reply checker's list of unit tokens it recognises in model prose",
|
||||
},
|
||||
"lib/ai/coach/eval/golden-cases.ts": {
|
||||
lines: 3,
|
||||
lines: 5,
|
||||
reason: "evaluation fixtures: scripted replies and ideal answers",
|
||||
},
|
||||
"lib/ai/coach/eval/red-team.ts": {
|
||||
|
||||
@@ -102,6 +102,11 @@ export const AI_ROUTES: Readonly<Record<string, AiRouteEntry>> = {
|
||||
capabilities: ["coach"],
|
||||
why: "POST streams a Coach reply from a model. The history GET in the same file asks nothing.",
|
||||
},
|
||||
"src/app/api/insights/chat/[id]/messages/[messageId]/trail/route.ts": {
|
||||
kind: "mixed",
|
||||
capabilities: ["coach"],
|
||||
why: "The trail's reasoning text is model-written and served only while the Coach's text may be shown; otherwise 200 with trail null and the ai state.",
|
||||
},
|
||||
"src/app/api/insights/generate/route.ts": {
|
||||
kind: "action",
|
||||
capabilities: ["briefing"],
|
||||
|
||||
@@ -332,7 +332,9 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
|
||||
// being available (the GDPR reason), and a timestamp on one's own row is
|
||||
// data too.
|
||||
"src/app/api/insights/coach/seen/route.ts",
|
||||
"src/app/api/insights/coach/facts/route.ts",
|
||||
// `insights/coach/facts/route.ts` is not listed, by the rule below: it
|
||||
// lists and erases without the Coach gate but keeps it on POST (the
|
||||
// remember button saves to the Coach's memory, which is Coach use).
|
||||
"src/app/api/insights/coach/facts/[id]/route.ts",
|
||||
// The stored plans list. Its sibling `[id]` route keeps the Coach gate on
|
||||
// PATCH (confirming a proposal is Coach use) and erases without it.
|
||||
|
||||
@@ -46,6 +46,8 @@ const ALL_ON = {
|
||||
assistantBriefingEnabled: true,
|
||||
assistantInsightStatusEnabled: true,
|
||||
assistantDocumentAiEnabled: true,
|
||||
aiReasoningEnabled: true,
|
||||
aiReasoningMaxEffort: "high",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -85,6 +87,34 @@ describe("GET /api/admin/settings/assistant-flags", () => {
|
||||
expect(body.data.raw.assistantEnabled).toBe(true);
|
||||
expect(body.data.resolved.coach).toBe(true);
|
||||
});
|
||||
|
||||
it("publishes the reasoning controls, defaulted on and uncapped", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null);
|
||||
const res = await (GET as unknown as (r: NextRequest) => Promise<Response>)(
|
||||
new NextRequest("http://localhost/api/admin/settings/assistant-flags"),
|
||||
);
|
||||
const body = (await res.json()) as {
|
||||
data: { reasoning: { enabled: boolean; maxEffort: string } };
|
||||
};
|
||||
expect(body.data.reasoning).toEqual({ enabled: true, maxEffort: "high" });
|
||||
});
|
||||
|
||||
it("reads a stored cap and an off switch back", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
...ALL_ON,
|
||||
aiReasoningEnabled: false,
|
||||
aiReasoningMaxEffort: "low",
|
||||
} as never);
|
||||
const res = await (GET as unknown as (r: NextRequest) => Promise<Response>)(
|
||||
new NextRequest("http://localhost/api/admin/settings/assistant-flags"),
|
||||
);
|
||||
const body = (await res.json()) as {
|
||||
data: { reasoning: { enabled: boolean; maxEffort: string } };
|
||||
};
|
||||
expect(body.data.reasoning).toEqual({ enabled: false, maxEffort: "low" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/admin/settings/assistant-flags", () => {
|
||||
@@ -141,6 +171,46 @@ describe("PUT /api/admin/settings/assistant-flags", () => {
|
||||
expect(body.data.resolved.documentAi).toBe(false);
|
||||
});
|
||||
|
||||
it("switches reasoning off and caps it, with an audit row", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never);
|
||||
vi.mocked(prisma.appSettings.upsert).mockResolvedValue({
|
||||
...ALL_ON,
|
||||
aiReasoningEnabled: false,
|
||||
aiReasoningMaxEffort: "medium",
|
||||
} as never);
|
||||
|
||||
const res = await PUT(
|
||||
putReq({ aiReasoningEnabled: false, aiReasoningMaxEffort: "medium" }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const upsert = vi.mocked(prisma.appSettings.upsert).mock.calls[0][0];
|
||||
expect(upsert.update).toEqual({
|
||||
aiReasoningEnabled: false,
|
||||
aiReasoningMaxEffort: "medium",
|
||||
});
|
||||
const { auditLog } = await import("@/lib/auth/audit");
|
||||
expect(vi.mocked(auditLog).mock.calls[0][1]).toMatchObject({
|
||||
details: { aiReasoningEnabled: false, aiReasoningMaxEffort: "medium" },
|
||||
});
|
||||
const body = (await res.json()) as {
|
||||
data: { reasoning: { enabled: boolean; maxEffort: string } };
|
||||
};
|
||||
expect(body.data.reasoning).toEqual({
|
||||
enabled: false,
|
||||
maxEffort: "medium",
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["off", "xhigh", "", 3])(
|
||||
"refuses %s as the highest level",
|
||||
async (value) => {
|
||||
vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never);
|
||||
const res = await PUT(putReq({ aiReasoningMaxEffort: value }));
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.appSettings.upsert).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects an empty body", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never);
|
||||
const res = await PUT(putReq({}));
|
||||
|
||||
@@ -28,6 +28,14 @@
|
||||
* so the admin UI can render the master vs sub-flag distinction
|
||||
* visually.
|
||||
*
|
||||
* v1.41 — the same route carries the operator's two reasoning controls:
|
||||
* `aiReasoningEnabled` (off wins over every person's Coach setting and every
|
||||
* background job) and `aiReasoningMaxEffort` (`low` | `medium` | `high`, the
|
||||
* highest level any call may take). Echoed as `reasoning: { enabled,
|
||||
* maxEffort }`. They sit beside the assistant switches rather than in them:
|
||||
* switching reasoning off stops no AI capability, it only stops the model
|
||||
* thinking longer before it answers.
|
||||
*
|
||||
* `requireAdmin()` gates the route — non-admins get 403.
|
||||
*/
|
||||
import type { NextRequest } from "next/server";
|
||||
@@ -45,6 +53,8 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { resolveAssistantFlags } from "@/lib/feature-flags";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { REASONING_MAX_EFFORTS } from "@/lib/ai/reasoning/levels";
|
||||
import { parseReasoningMaxEffort } from "@/lib/ai/reasoning/resolve";
|
||||
|
||||
const assistantFlagsSchema = z
|
||||
.object({
|
||||
@@ -53,6 +63,8 @@ const assistantFlagsSchema = z
|
||||
assistantBriefingEnabled: z.boolean().optional(),
|
||||
assistantInsightStatusEnabled: z.boolean().optional(),
|
||||
assistantDocumentAiEnabled: z.boolean().optional(),
|
||||
aiReasoningEnabled: z.boolean().optional(),
|
||||
aiReasoningMaxEffort: z.enum(REASONING_MAX_EFFORTS).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -62,8 +74,20 @@ type AssistantFlagsRow = {
|
||||
assistantBriefingEnabled: boolean;
|
||||
assistantInsightStatusEnabled: boolean;
|
||||
assistantDocumentAiEnabled: boolean;
|
||||
aiReasoningEnabled: boolean;
|
||||
aiReasoningMaxEffort: string;
|
||||
};
|
||||
|
||||
const ROW_SELECT = {
|
||||
assistantEnabled: true,
|
||||
assistantCoachEnabled: true,
|
||||
assistantBriefingEnabled: true,
|
||||
assistantInsightStatusEnabled: true,
|
||||
assistantDocumentAiEnabled: true,
|
||||
aiReasoningEnabled: true,
|
||||
aiReasoningMaxEffort: true,
|
||||
} as const;
|
||||
|
||||
function buildResponseShape(row: AssistantFlagsRow) {
|
||||
const resolved = resolveAssistantFlags({
|
||||
enabled: row.assistantEnabled,
|
||||
@@ -81,6 +105,10 @@ function buildResponseShape(row: AssistantFlagsRow) {
|
||||
assistantDocumentAiEnabled: row.assistantDocumentAiEnabled,
|
||||
},
|
||||
resolved,
|
||||
reasoning: {
|
||||
enabled: row.aiReasoningEnabled,
|
||||
maxEffort: parseReasoningMaxEffort(row.aiReasoningMaxEffort),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -92,13 +120,7 @@ export const GET = apiHandler(async () => {
|
||||
|
||||
const settings = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: {
|
||||
assistantEnabled: true,
|
||||
assistantCoachEnabled: true,
|
||||
assistantBriefingEnabled: true,
|
||||
assistantInsightStatusEnabled: true,
|
||||
assistantDocumentAiEnabled: true,
|
||||
},
|
||||
select: ROW_SELECT,
|
||||
});
|
||||
|
||||
const row: AssistantFlagsRow = {
|
||||
@@ -108,6 +130,8 @@ export const GET = apiHandler(async () => {
|
||||
assistantInsightStatusEnabled:
|
||||
settings?.assistantInsightStatusEnabled ?? true,
|
||||
assistantDocumentAiEnabled: settings?.assistantDocumentAiEnabled ?? true,
|
||||
aiReasoningEnabled: settings?.aiReasoningEnabled ?? true,
|
||||
aiReasoningMaxEffort: settings?.aiReasoningMaxEffort ?? "high",
|
||||
};
|
||||
|
||||
return apiSuccess(buildResponseShape(row));
|
||||
@@ -128,14 +152,15 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
// Field by field from the parsed body; the schema is strict, so every key
|
||||
// here is one the route owns.
|
||||
const updates: Partial<AssistantFlagsRow> = {};
|
||||
const auditDetails: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(parsed.data)) {
|
||||
if (typeof value === "boolean") {
|
||||
(updates as Record<string, boolean>)[key] = value;
|
||||
auditDetails[key] = value;
|
||||
if (value !== undefined) {
|
||||
(updates as Record<string, boolean | string>)[key] = value;
|
||||
}
|
||||
}
|
||||
const auditDetails: Record<string, unknown> = { ...updates };
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return apiError("No valid fields", 422);
|
||||
@@ -145,6 +170,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
where: { id: "singleton" },
|
||||
update: updates,
|
||||
create: { id: "singleton", ...updates },
|
||||
select: ROW_SELECT,
|
||||
});
|
||||
|
||||
await auditLog("admin.settings.assistant-flags.update", {
|
||||
@@ -159,6 +185,8 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
assistantBriefingEnabled: settings.assistantBriefingEnabled,
|
||||
assistantInsightStatusEnabled: settings.assistantInsightStatusEnabled,
|
||||
assistantDocumentAiEnabled: settings.assistantDocumentAiEnabled,
|
||||
aiReasoningEnabled: settings.aiReasoningEnabled,
|
||||
aiReasoningMaxEffort: settings.aiReasoningMaxEffort,
|
||||
};
|
||||
|
||||
return apiSuccess(buildResponseShape(row));
|
||||
|
||||
@@ -172,3 +172,50 @@ describe("PUT /api/auth/me/coach-prefs — optimistic concurrency", () => {
|
||||
expect(prisma.user.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/auth/me/coach-prefs — thinking depth (v1.41)", () => {
|
||||
it("stores the chosen level and names it on the wide event", async () => {
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({
|
||||
updatedAt: new Date("2026-10-06T11:00:00.000Z"),
|
||||
} as never);
|
||||
const { annotate } = await import("@/lib/logging/context");
|
||||
|
||||
const res = await (PUT as (r: Request) => Promise<Response>)(
|
||||
mkPut({ reasoning: "high" }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const call = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as {
|
||||
data: { coachPrefsJson: { reasoning?: string } };
|
||||
};
|
||||
expect(call.data.coachPrefsJson.reasoning).toBe("high");
|
||||
const env = (await res.json()) as { data: { reasoning?: string } };
|
||||
expect(env.data.reasoning).toBe("high");
|
||||
const meta = vi
|
||||
.mocked(annotate)
|
||||
.mock.calls.map((c) => c[0])
|
||||
.find((a) => a.action?.name === "auth.me.coach-prefs.put")?.meta;
|
||||
expect(meta).toMatchObject({ reasoning: "high" });
|
||||
});
|
||||
|
||||
it("reads an absent level as the default on the wide event", async () => {
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({
|
||||
updatedAt: new Date("2026-10-06T11:00:00.000Z"),
|
||||
} as never);
|
||||
const { annotate } = await import("@/lib/logging/context");
|
||||
|
||||
await (PUT as (r: Request) => Promise<Response>)(mkPut({ tone: "warm" }));
|
||||
const meta = vi
|
||||
.mocked(annotate)
|
||||
.mock.calls.map((c) => c[0])
|
||||
.find((a) => a.action?.name === "auth.me.coach-prefs.put")?.meta;
|
||||
expect(meta).toMatchObject({ reasoning: "medium" });
|
||||
});
|
||||
|
||||
it.each(["xhigh", "none", 2, null])("422s %s as a level", async (value) => {
|
||||
const res = await (PUT as (r: Request) => Promise<Response>)(
|
||||
mkPut({ reasoning: value }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from "@/lib/optimistic-lock";
|
||||
import {
|
||||
coachPrefsSchema,
|
||||
coachReasoningLevel,
|
||||
parseCoachPrefs,
|
||||
} from "@/lib/validations/coach-prefs";
|
||||
|
||||
@@ -130,6 +131,10 @@ export const PUT = apiHandler(async (req: Request) => {
|
||||
// v1.7.0 — `null` when the caller left `dataClusters` absent
|
||||
// (legacy defaults stay in force); otherwise the selected count.
|
||||
clusterCount: parsed.data.dataClusters?.length ?? null,
|
||||
// v1.41 — the stored thinking depth, absent read as the default. The
|
||||
// operator's switch and cap apply on top of it at turn time; the
|
||||
// resolved value is `coachReasoning` on GET /api/auth/me.
|
||||
reasoning: coachReasoningLevel(parsed.data),
|
||||
},
|
||||
});
|
||||
return apiSuccess({ ...parsed.data, updatedAt: guarded.updatedAt });
|
||||
|
||||
@@ -72,6 +72,8 @@ import {
|
||||
} from "@/lib/sharing/module-disclosure";
|
||||
import { recordSessionForPayload } from "@/lib/sharing/record-session-fence";
|
||||
import { loadAiCapabilities } from "@/lib/ai/capabilities/load";
|
||||
import { loadCoachReasoningState } from "@/lib/ai/reasoning/controls";
|
||||
import { parseCoachPrefs } from "@/lib/validations/coach-prefs";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -143,6 +145,7 @@ export const GET = apiHandler(async () => {
|
||||
moduleAvailability,
|
||||
onboardingRow,
|
||||
ai,
|
||||
coachReasoning,
|
||||
] = await Promise.all([
|
||||
// The actor's own row is already in hand; a switched session needs the
|
||||
// record's `gender`, which is the column the cycle gate derives from.
|
||||
@@ -206,6 +209,16 @@ export const GET = apiHandler(async () => {
|
||||
sections,
|
||||
recordKind,
|
||||
}),
|
||||
// v1.41 — the Coach's thinking depth, resolved: the caller's own
|
||||
// preference (Coach preferences belong to the person, like every other
|
||||
// display preference here) against the operator's switch and cap and the
|
||||
// record's provider. The provider read is the memoised presence probe the
|
||||
// `ai` block above already ran.
|
||||
loadCoachReasoningState({
|
||||
prefs: parseCoachPrefs(user.coachPrefsJson),
|
||||
recordId,
|
||||
authority: aiAuthority,
|
||||
}),
|
||||
]);
|
||||
|
||||
// The masking step, and the one place the record scoping is narrowed rather
|
||||
@@ -397,6 +410,14 @@ export const GET = apiHandler(async () => {
|
||||
// the RECORD and masked to the grant, like `modules`. Clients render from
|
||||
// it and never recompute it; data never depends on it.
|
||||
ai,
|
||||
// v1.41 — the Coach's thinking depth, resolved on the server so the web
|
||||
// settings and the native client show the same answer: `level` is what a
|
||||
// turn runs at now, `preference` what the person picked, `maxLevel` the
|
||||
// highest option the settings may offer (`off` when the operator switched
|
||||
// reasoning off), `available` whether reasoning can run at all,
|
||||
// `offIsReal` whether the lowest option is a real off or "Minimal", and
|
||||
// `source` which layer decided. Clients never recompute it.
|
||||
coachReasoning,
|
||||
// v1.36.0 — account sharing, resolved. `accounts` is the switcher's menu,
|
||||
// `active` is the record this session is inside (null when it is in its
|
||||
// own), `canSwitch` and per-entry `canWrite` are the booleans the UI binds
|
||||
|
||||
@@ -36,6 +36,7 @@ import { prisma } from "@/lib/db";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { clampReviewDays } from "@/lib/ai/coach/memory/propose-plan";
|
||||
import { COACH_CHECKIN_REVIEW_DAYS } from "@/lib/daily/coach-checkin-intents";
|
||||
import { coachPlanPatchSchema } from "@/lib/validations/coach-plan";
|
||||
|
||||
@@ -102,15 +103,25 @@ export const PATCH = apiHandler(async (req: NextRequest, ctx: RouteCtx) => {
|
||||
// +COACH_CHECKIN_REVIEW_DAYS so the Today check-in card fires in a week. Read
|
||||
// the current row first so an explicitly-pinned review date is never
|
||||
// clobbered; the read stays owner-scoped, so it leaks no cross-account state.
|
||||
// v1.41 — a plan the Coach proposed in a turn carries its review window as
|
||||
// the distance from the proposal to its `reviewDate`; taking it on here
|
||||
// counts that window from now, exactly like the tap under the answer
|
||||
// (`decidePlanProposal`).
|
||||
if (parsed.data.status === "active" && parsed.data.reviewDate === undefined) {
|
||||
const current = await prisma.coachPlan.findFirst({
|
||||
where: { id, userId: user.id, deletedAt: null },
|
||||
select: { reviewDate: true },
|
||||
select: { reviewDate: true, status: true, createdAt: true },
|
||||
});
|
||||
if (current && current.reviewDate === null) {
|
||||
data.reviewDate = new Date(
|
||||
Date.now() + COACH_CHECKIN_REVIEW_DAYS * 86_400_000,
|
||||
);
|
||||
} else if (current?.reviewDate && current.status === "proposed") {
|
||||
const days = clampReviewDays(
|
||||
(current.reviewDate.getTime() - current.createdAt.getTime()) /
|
||||
86_400_000,
|
||||
);
|
||||
data.reviewDate = new Date(Date.now() + days * 86_400_000);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
* Coach proposes. A plan is an "if-then" implementation intention tied to one
|
||||
* metric, with an optional target. The Coach extractor writes a plan as
|
||||
* `status: "proposed"`; only the user-facing PATCH (`/api/coach/plans/[id]`)
|
||||
* activates it. There is no silent self-edit of the user's plan set.
|
||||
* activates it. There is no silent self-edit of the user's plan set. A plan
|
||||
* the Coach proposes in a turn (`propose_plan`, v1.41) exists only once the
|
||||
* answer carrying it is stored (`memory/turn-writes.ts`), so a turn that was
|
||||
* blocked, failed or abandoned has nothing here to list.
|
||||
*
|
||||
* Ownership: every query is scoped `where: { userId, ... }`, so a caller can
|
||||
* only ever see their own plans. The free-text fields (if-cue, then-action,
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* v1.41 — the trail route: owner-narrowed, 404 for anything else, the
|
||||
* model-written text only while the Coach's text may be shown, and the
|
||||
* person's own facts (recalled, proposed) always, minus medication facts
|
||||
* while the medications module is off.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
import type * as ApiHandlerModule from "@/lib/api-handler";
|
||||
|
||||
const {
|
||||
requireAuth,
|
||||
readMessageTrail,
|
||||
aiCapabilityToServe,
|
||||
isModuleEnabled,
|
||||
findMany,
|
||||
} = vi.hoisted(() => ({
|
||||
requireAuth: vi.fn(),
|
||||
readMessageTrail: vi.fn(),
|
||||
aiCapabilityToServe: vi.fn(),
|
||||
isModuleEnabled: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof ApiHandlerModule>();
|
||||
return {
|
||||
...actual,
|
||||
apiHandler: <T extends (...args: never[]) => unknown>(handler: T) =>
|
||||
handler,
|
||||
requireAuth,
|
||||
};
|
||||
});
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/ai/coach/persistence", () => ({ readMessageTrail }));
|
||||
vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityToServe }));
|
||||
vi.mock("@/lib/modules/gate", () => ({ isModuleEnabled }));
|
||||
vi.mock("@/lib/db", () => ({ prisma: { coachFact: { findMany } } }));
|
||||
vi.mock("@/lib/ai/coach/bytes-codec", () => ({
|
||||
decryptFromBytes: (b: Uint8Array) => new TextDecoder().decode(b),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
|
||||
const USER_ID = "user-1";
|
||||
const TRAIL = {
|
||||
entries: [
|
||||
{
|
||||
id: "a1",
|
||||
title: "Weighing the two weeks",
|
||||
text: "Looked at sleep first.",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function call(id = "c1", messageId = "m1") {
|
||||
return GET(
|
||||
new NextRequest(
|
||||
`http://localhost/api/insights/chat/${id}/messages/${messageId}/trail`,
|
||||
),
|
||||
{ params: Promise.resolve({ id, messageId }) },
|
||||
) as Promise<Response>;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
requireAuth.mockResolvedValue({ user: { id: USER_ID } });
|
||||
aiCapabilityToServe.mockResolvedValue({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
});
|
||||
isModuleEnabled.mockResolvedValue(true);
|
||||
findMany.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
const UNAVAILABLE = {
|
||||
available: false,
|
||||
reason: "disabled_by_user",
|
||||
onDeviceAllowed: false,
|
||||
};
|
||||
|
||||
const WITH_FACTS = {
|
||||
...TRAIL,
|
||||
recalled: ["Prefers walks after dinner", "Takes Mounjaro since May"],
|
||||
proposal: {
|
||||
proposalId: "mp_1",
|
||||
category: "condition",
|
||||
fact: "Has asthma",
|
||||
},
|
||||
};
|
||||
|
||||
describe("GET /api/insights/chat/[id]/messages/[messageId]/trail", () => {
|
||||
it("reads under the caller's own id and the path ids only", async () => {
|
||||
readMessageTrail.mockResolvedValue({ trail: TRAIL });
|
||||
const response = await call("c1", "m1");
|
||||
expect(response.status).toBe(200);
|
||||
expect(readMessageTrail).toHaveBeenCalledWith(USER_ID, "c1", "m1");
|
||||
// Requires a session or a full-access token, like every Coach read.
|
||||
expect(requireAuth).toHaveBeenCalledWith();
|
||||
const body = await response.json();
|
||||
expect(body.data.trail).toEqual(TRAIL);
|
||||
expect(body.data.ai.available).toBe(true);
|
||||
});
|
||||
|
||||
it("answers 404 for a message the caller does not own", async () => {
|
||||
readMessageTrail.mockResolvedValue(null);
|
||||
const response = await call();
|
||||
expect(response.status).toBe(404);
|
||||
expect(aiCapabilityToServe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("serves no model text while the Coach's text may not be shown", async () => {
|
||||
readMessageTrail.mockResolvedValue({ trail: TRAIL });
|
||||
aiCapabilityToServe.mockResolvedValue({
|
||||
available: false,
|
||||
reason: "disabled_by_user",
|
||||
onDeviceAllowed: false,
|
||||
});
|
||||
const response = await call();
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
expect(body.data.trail).toBeNull();
|
||||
expect(body.data.ai.available).toBe(false);
|
||||
expect(aiCapabilityToServe).toHaveBeenCalledWith(USER_ID, "coach");
|
||||
});
|
||||
|
||||
it("serves the person's own facts while the model text is withheld", async () => {
|
||||
readMessageTrail.mockResolvedValue({ trail: WITH_FACTS });
|
||||
aiCapabilityToServe.mockResolvedValue(UNAVAILABLE);
|
||||
const body = await (await call()).json();
|
||||
expect(body.data.trail).toEqual({
|
||||
entries: [],
|
||||
recalled: WITH_FACTS.recalled,
|
||||
proposal: WITH_FACTS.proposal,
|
||||
});
|
||||
expect(body.data.ai.available).toBe(false);
|
||||
});
|
||||
|
||||
it("withholds medication facts while the medications module is off", async () => {
|
||||
readMessageTrail.mockResolvedValue({
|
||||
trail: {
|
||||
...WITH_FACTS,
|
||||
recalled: [...WITH_FACTS.recalled, "Morning pill with coffee"],
|
||||
proposal: {
|
||||
proposalId: "mp_1",
|
||||
category: "medication",
|
||||
fact: "Takes ramipril",
|
||||
},
|
||||
},
|
||||
});
|
||||
isModuleEnabled.mockResolvedValue(false);
|
||||
// A stored fact filed under medication whose words the lexicon misses.
|
||||
findMany.mockResolvedValue([
|
||||
{ factEncrypted: new TextEncoder().encode("Morning pill with coffee") },
|
||||
]);
|
||||
const body = await (await call()).json();
|
||||
expect(isModuleEnabled).toHaveBeenCalledWith(USER_ID, "medications");
|
||||
expect(findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { userId: USER_ID, category: "medication" },
|
||||
}),
|
||||
);
|
||||
expect(body.data.trail).toEqual({
|
||||
entries: TRAIL.entries,
|
||||
recalled: ["Prefers walks after dinner"],
|
||||
});
|
||||
});
|
||||
|
||||
it("answers a message without a trail with null", async () => {
|
||||
readMessageTrail.mockResolvedValue({ trail: null });
|
||||
const body = await (await call()).json();
|
||||
expect(body.data.trail).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* GET /api/insights/chat/[id]/messages/[messageId]/trail — the text of one
|
||||
* assistant message's trail (v1.41), decrypted for its owner: the screened
|
||||
* reasoning titles and summaries and checkpoint sentences by trail entry,
|
||||
* the facts the turn recalled, and a fact proposal it offered. The structure
|
||||
* of the trail (phases, statuses, counts) is plaintext on the message's
|
||||
* `metricSource.activity`; only the text is here, read lazily so a long
|
||||
* thread does not carry every trail.
|
||||
*
|
||||
* Narrowed exactly like `…/results`: the message must sit in a conversation
|
||||
* the caller owns, and a foreign or unknown id is 404, never 403. A session
|
||||
* or a full-access token reads it; a narrow token is refused by
|
||||
* `requireAuth()` like on every Coach read.
|
||||
*
|
||||
* A mixed read, split by who wrote what. The entries' text is model-written,
|
||||
* so it is served only while the Coach's text may be shown for the record
|
||||
* (`aiCapabilityToServe`); when it may not, `entries` is empty, the `ai`
|
||||
* state rides beside it, and the client keeps the trail's catalog labels.
|
||||
* The recalled facts and the proposal's fact are the person's own data and
|
||||
* are served to the owner whatever the capability, like the memory list:
|
||||
* only the medications module gates them, at read time, the way `…/results`
|
||||
* withholds a table whose module is off. `trail` is null when nothing is
|
||||
* left to serve.
|
||||
*/
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
|
||||
import { readMessageTrail } from "@/lib/ai/coach/persistence";
|
||||
import { withholdMedicationFacts } from "@/lib/ai/coach/memory/trail-read";
|
||||
import { isModuleEnabled } from "@/lib/modules/gate";
|
||||
import type { CoachTrail } from "@/lib/ai/coach/types";
|
||||
|
||||
interface RouteCtx {
|
||||
params: Promise<{ id: string; messageId: string }>;
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async (_request: NextRequest, ctx: RouteCtx) => {
|
||||
const auth = await requireAuth();
|
||||
const userId = auth.user.id;
|
||||
const { id, messageId } = await ctx.params;
|
||||
if (!id || !messageId) return apiError("coach.message.notFound", 404);
|
||||
|
||||
const read = await readMessageTrail(userId, id, messageId);
|
||||
if (!read) return apiError("coach.message.notFound", 404);
|
||||
|
||||
const ai = await aiCapabilityToServe(userId, "coach");
|
||||
const stored = read.trail;
|
||||
let trail: CoachTrail | null = null;
|
||||
let withheld = 0;
|
||||
if (stored) {
|
||||
const medicationsOn = await isModuleEnabled(userId, "medications");
|
||||
const facts = medicationsOn
|
||||
? { recalled: stored.recalled, proposal: stored.proposal, withheld: 0 }
|
||||
: await withholdMedicationFacts(userId, stored);
|
||||
withheld = facts.withheld;
|
||||
const entries = ai.available ? stored.entries : [];
|
||||
const recalled =
|
||||
facts.recalled && facts.recalled.length > 0 ? facts.recalled : undefined;
|
||||
if (entries.length > 0 || recalled || facts.proposal) {
|
||||
trail = {
|
||||
entries,
|
||||
...(recalled ? { recalled } : {}),
|
||||
...(facts.proposal ? { proposal: facts.proposal } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "insights.coach.trail.read" },
|
||||
meta: {
|
||||
conversationId: id,
|
||||
entries: trail?.entries.length ?? 0,
|
||||
recalled: trail?.recalled?.length ?? 0,
|
||||
withheld,
|
||||
served: ai.available,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({ trail, ai });
|
||||
});
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
+1489
-108
File diff suppressed because it is too large.
Load diff
@@ -63,6 +63,7 @@ export interface ProviderCall {
|
||||
system: string;
|
||||
messages: Array<{ role: string; content: string }>;
|
||||
toolChoice: string | undefined;
|
||||
cacheKey: string | undefined;
|
||||
toolCalls: Array<{ name: string; args: Record<string, unknown> }>;
|
||||
}
|
||||
export const providerCalls: ProviderCall[] = [];
|
||||
@@ -278,6 +279,7 @@ async function runRawCompletionWithFallback(args: {
|
||||
system: string;
|
||||
messages: Array<{ role: string; content: string }>;
|
||||
toolChoice?: string;
|
||||
cacheKey?: string;
|
||||
};
|
||||
}) {
|
||||
const round = world.script.shift() ?? { text: "" };
|
||||
@@ -289,6 +291,7 @@ async function runRawCompletionWithFallback(args: {
|
||||
content: msg.content,
|
||||
})),
|
||||
toolChoice: args.params.toolChoice,
|
||||
cacheKey: args.params.cacheKey,
|
||||
toolCalls: calls,
|
||||
});
|
||||
const offered = args.params.toolChoice !== "none";
|
||||
@@ -458,7 +461,11 @@ export const modules = {
|
||||
coachConversation: {
|
||||
findFirst: async () => ({ id: CONVERSATION_ID }),
|
||||
},
|
||||
coachMessage: { findMany: async () => latestRows() },
|
||||
coachMessage: {
|
||||
findMany: async () => latestRows(),
|
||||
// v1.41 — the clarification brake's count of today's questions.
|
||||
count: async () => 0,
|
||||
},
|
||||
measurement: {
|
||||
groupBy: async (args: { where: { type: { in: string[] } } }) =>
|
||||
measurementBounds(args),
|
||||
@@ -473,7 +480,10 @@ export const modules = {
|
||||
labResult: { aggregate: async () => EMPTY_AGGREGATE },
|
||||
},
|
||||
}),
|
||||
rateLimit: () => ({ checkRateLimit: m.checkRateLimit }),
|
||||
rateLimit: () => ({
|
||||
checkRateLimit: m.checkRateLimit,
|
||||
refundRateLimit: async () => {},
|
||||
}),
|
||||
serverLocale: () => ({
|
||||
resolveServerLocale: async (args: { override?: string }) =>
|
||||
args.override ?? "en",
|
||||
|
||||
@@ -175,6 +175,8 @@ export function resetGolden(): void {
|
||||
},
|
||||
);
|
||||
m.getEvent.mockReturnValue({
|
||||
// A request event, so the per-request cache memoises as in production.
|
||||
getKind: () => "request",
|
||||
setError: (err: Error) =>
|
||||
record("event.setError", { message: err.message }),
|
||||
});
|
||||
@@ -373,7 +375,26 @@ export async function runCoachToolLoop(args: Record<string, unknown>) {
|
||||
timeoutMs: args.timeoutMs,
|
||||
signal: args.signal instanceof AbortSignal,
|
||||
});
|
||||
return m.runCoachToolLoop(args);
|
||||
const out = await m.runCoachToolLoop(args);
|
||||
// The real loop settles each round on the turn's ledger as it returns;
|
||||
// the scripted loop settles its whole cost as one round.
|
||||
const spend = args.spend as
|
||||
| {
|
||||
settleRound(usage: {
|
||||
tokens: number;
|
||||
cachedTokens: number;
|
||||
servedBy: string;
|
||||
final: boolean;
|
||||
}): Promise<void>;
|
||||
}
|
||||
| undefined;
|
||||
await spend?.settleRound({
|
||||
tokens: out.totalTokens,
|
||||
cachedTokens: out.cachedTokens,
|
||||
servedBy: out.workingProviderType,
|
||||
final: false,
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function runStreamingRawCompletionWithFallback(
|
||||
|
||||
@@ -63,9 +63,14 @@ vi.mock("@/lib/db", () => ({
|
||||
user: { findUnique: h.m.userFindUnique, update: h.m.userUpdate },
|
||||
coachConversation: { findFirst: h.m.conversationFindFirst },
|
||||
coachMessage: { findMany: h.m.coachMessageFindMany },
|
||||
// v1.41 — the operator's reasoning controls; an untouched instance.
|
||||
appSettings: { findUnique: vi.fn(async () => null) },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: h.m.checkRateLimit }));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: h.m.checkRateLimit,
|
||||
refundRateLimit: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/lib/i18n/server-locale", () => ({
|
||||
resolveServerLocale: h.m.resolveServerLocale,
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* v1.41 — the live trail and the dialog of a turn through the real route,
|
||||
* pipeline and loop, with a scripted provider:
|
||||
*
|
||||
* - frame order: the trail and the steps first (thinking before the
|
||||
* provider is called, each fetch, the digest), interim tables as soon as
|
||||
* they are read, the answer entry before the first token, then the reply
|
||||
* frames as before;
|
||||
* - the stored trail carries the model text, the provenance only the
|
||||
* structure;
|
||||
* - a question asked through the tool ends the turn with its choices;
|
||||
* - a person may run two turns at once, not three;
|
||||
* - a tapped decision is answered without a model, and a stale one goes to
|
||||
* the model as an ordinary message.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const h = await vi.hoisted(
|
||||
() => import("@/app/api/insights/chat/__tests__/dialog-harness"),
|
||||
);
|
||||
const contract = vi.hoisted(() => ({
|
||||
decideFactProposal: vi.fn(),
|
||||
decidePlanProposal: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", () => h.modules.apiHandler());
|
||||
vi.mock("@/lib/api-response", () => h.modules.apiResponse());
|
||||
vi.mock("@/lib/modules/gate", () => h.modules.gate());
|
||||
vi.mock("@/lib/ai/capabilities/gate", () => h.modules.capabilities());
|
||||
vi.mock("@/lib/logging/context", () => h.modules.logging());
|
||||
vi.mock("@/lib/auth/audit", () => h.modules.audit());
|
||||
vi.mock("@/lib/db", () => h.modules.db());
|
||||
vi.mock("@/lib/rate-limit", () => h.modules.rateLimit());
|
||||
vi.mock("@/lib/i18n/server-locale", () => h.modules.serverLocale());
|
||||
vi.mock("@/lib/ai/provider-runner", () => h.modules.providerRunner());
|
||||
vi.mock("@/lib/ai/provider", () => h.modules.provider());
|
||||
vi.mock("@/lib/ai/consent-guard", () => h.modules.consent());
|
||||
vi.mock("@/lib/ai/coach/persistence", () => h.modules.persistence());
|
||||
vi.mock("@/lib/ai/coach/coach-memory-shared", () => h.modules.memory());
|
||||
vi.mock("@/lib/ai/coach/facts", () => h.modules.facts());
|
||||
vi.mock("@/lib/ai/coach/budget", () => h.modules.budget());
|
||||
vi.mock("@/lib/ai/coach/about-me", () => h.modules.aboutMe());
|
||||
vi.mock("@/lib/ai/coach/snapshot", () => h.modules.snapshot());
|
||||
vi.mock("@/lib/medications/scheduled-doses", () => h.modules.scheduledDoses());
|
||||
vi.mock("@/lib/ai/coach/workout-evidence-builder", () =>
|
||||
h.modules.workoutEvidence(),
|
||||
);
|
||||
vi.mock("@/lib/ai/coach/suggest-gate", () => h.modules.suggestGate());
|
||||
vi.mock("@/lib/monitoring-settings", () => h.modules.glitchtipSettings());
|
||||
vi.mock("@/lib/monitoring/glitchtip", () => h.modules.glitchtip());
|
||||
vi.mock("@/lib/tz/resolver", () => h.modules.timezone());
|
||||
vi.mock("@/lib/measurements/daily-series-read", () => h.modules.dailySeries());
|
||||
vi.mock("@/lib/rollups/measurement-read", () => h.modules.sourcePriority());
|
||||
vi.mock("@/lib/ai/coach/bytes-codec", () => h.modules.bytesCodec());
|
||||
vi.mock("@/lib/ai/coach/tools/inventory", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
buildCoachDataInventory: h.modules.buildInventory,
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/memory/contract", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
decideFactProposal: contract.decideFactProposal,
|
||||
decidePlanProposal: contract.decidePlanProposal,
|
||||
}));
|
||||
|
||||
import type { CoachActivity, CoachProvenance } from "@/lib/ai/coach/types";
|
||||
import type { InventoryEntry } from "@/lib/ai/coach/tools/inventory";
|
||||
import { POST } from "@/app/api/insights/chat/route";
|
||||
|
||||
const post = POST as unknown as (req: Request) => Promise<Response>;
|
||||
const { world, providerCalls, framesOf, m } = h;
|
||||
|
||||
const series = (metric: string, count: number): InventoryEntry => ({
|
||||
tool: "get_metric_series",
|
||||
metric,
|
||||
domain: metric,
|
||||
present: true,
|
||||
count,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.setSystemTime(h.NOW);
|
||||
h.resetDialog();
|
||||
contract.decideFactProposal.mockReset();
|
||||
contract.decidePlanProposal.mockReset();
|
||||
world.inventory = [
|
||||
series("bp", 58),
|
||||
series("pulse", 61),
|
||||
series("resting_hr", 240),
|
||||
series("walking_hr", 212),
|
||||
];
|
||||
});
|
||||
|
||||
function rank(frame: { type: string; interim?: boolean }): number {
|
||||
if (frame.type === "activity" || frame.type === "step") return 0;
|
||||
if (frame.type === "result" && frame.interim) return 0;
|
||||
return [
|
||||
"live",
|
||||
"token",
|
||||
"provenance",
|
||||
"result",
|
||||
"suggestion",
|
||||
"suggestedAction",
|
||||
"memoryNote",
|
||||
"planProposal",
|
||||
"clarification",
|
||||
"followUps",
|
||||
"done",
|
||||
].indexOf(frame.type);
|
||||
}
|
||||
|
||||
describe("the live trail", () => {
|
||||
it("streams every phase before the first token, in the promised order", async () => {
|
||||
world.script = [
|
||||
{
|
||||
text: "Let me look at your blood pressure first.",
|
||||
calls: [
|
||||
{
|
||||
name: "get_metric_table",
|
||||
args: { metric: "bp", window: "last30days" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ text: "Your blood pressure held steady. result:r1" },
|
||||
];
|
||||
const { status, frames } = await h.postTurn(post, {
|
||||
message: "How was my blood pressure?",
|
||||
locale: "en",
|
||||
});
|
||||
expect(status).toBe(200);
|
||||
expect(frames.at(-1)?.type).toBe("done");
|
||||
const order = frames.map(rank);
|
||||
expect(order.every((r) => r >= 0)).toBe(true);
|
||||
expect([...order].sort((a, b) => a - b)).toEqual(order);
|
||||
|
||||
const activity = framesOf<{ activity: CoachActivity }>(
|
||||
frames,
|
||||
"activity",
|
||||
).map((f) => f.activity);
|
||||
// The thinking entry opens first; the answer entry is the last live one.
|
||||
expect(activity[0]).toMatchObject({ phase: "thinking", status: "running" });
|
||||
expect(activity.at(-1)).toMatchObject({
|
||||
phase: "answer",
|
||||
status: "running",
|
||||
});
|
||||
const phases = new Set(activity.map((a) => a.phase));
|
||||
for (const phase of [
|
||||
"thinking",
|
||||
"checkpoint",
|
||||
"fetch",
|
||||
"digest",
|
||||
"answer",
|
||||
]) {
|
||||
expect(phases).toContain(phase);
|
||||
}
|
||||
const fetch = activity.filter((a) => a.phase === "fetch").at(-1)!;
|
||||
expect(fetch).toMatchObject({
|
||||
status: "done",
|
||||
stepRef: "s1",
|
||||
labelKey: "insights.coach.activity.fetching",
|
||||
});
|
||||
expect(fetch.label).toBe("Fetching Blood pressure, last 30 days…");
|
||||
const checkpoint = activity.filter((a) => a.phase === "checkpoint").at(-1)!;
|
||||
expect(checkpoint.title).toBe("Let me look at your blood pressure first.");
|
||||
|
||||
// The table went out as soon as it was read, before the first token.
|
||||
const firstToken = frames.findIndex((f) => f.type === "token");
|
||||
const interim = frames.findIndex(
|
||||
(f) => f.type === "result" && f.interim === true,
|
||||
);
|
||||
expect(interim).toBeGreaterThan(-1);
|
||||
expect(interim).toBeLessThan(firstToken);
|
||||
});
|
||||
|
||||
it("persists the structure on the provenance and the text in the trail", async () => {
|
||||
world.script = [
|
||||
{
|
||||
text: "Let me look at your blood pressure first.",
|
||||
calls: [
|
||||
{
|
||||
name: "get_metric_table",
|
||||
args: { metric: "bp", window: "last30days" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ text: "Your blood pressure held steady. result:r1" },
|
||||
];
|
||||
await h.postTurn(post, {
|
||||
message: "How was my blood pressure?",
|
||||
locale: "en",
|
||||
});
|
||||
const assistant = m.appendMessage.mock.calls
|
||||
.map(
|
||||
(c) =>
|
||||
c[0] as {
|
||||
role: string;
|
||||
metricSource?: CoachProvenance;
|
||||
trail?: unknown;
|
||||
},
|
||||
)
|
||||
.find((args) => args.role === "assistant")!;
|
||||
const provenance = assistant.metricSource!;
|
||||
expect(provenance.activity?.length).toBeGreaterThan(3);
|
||||
// The answer entry is stored closed.
|
||||
expect(provenance.activity?.at(-1)).toMatchObject({
|
||||
phase: "answer",
|
||||
status: "done",
|
||||
});
|
||||
expect(JSON.stringify(provenance)).not.toContain(
|
||||
"look at your blood pressure",
|
||||
);
|
||||
expect(assistant.trail).toMatchObject({
|
||||
entries: [
|
||||
expect.objectContaining({
|
||||
title: "Let me look at your blood pressure first.",
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("a question asked through the tool", () => {
|
||||
it("ends the turn with the question and its choices, no chips", async () => {
|
||||
world.script = [
|
||||
{
|
||||
calls: [
|
||||
{
|
||||
name: "ask_clarification",
|
||||
args: {
|
||||
kind: "metric",
|
||||
question:
|
||||
"Do you mean resting or walking heart rate? Otherwise I'll look at resting.",
|
||||
choices: ["resting_hr", "walking_hr"],
|
||||
assumption: "resting_hr",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const { frames } = await h.postTurn(post, {
|
||||
message: "Is my heart rate good?",
|
||||
locale: "en",
|
||||
});
|
||||
expect(providerCalls).toHaveLength(1);
|
||||
const prose = framesOf<{ token: string }>(frames, "token")
|
||||
.map((f) => f.token)
|
||||
.join("");
|
||||
expect(prose).toBe(
|
||||
"Do you mean resting or walking heart rate? Otherwise I'll look at resting.",
|
||||
);
|
||||
const [clarification] = framesOf<{
|
||||
clarification: { kind: string; assumption?: string; choices: unknown[] };
|
||||
}>(frames, "clarification");
|
||||
expect(clarification.clarification).toMatchObject({
|
||||
kind: "metric",
|
||||
assumption: "c1",
|
||||
});
|
||||
expect(framesOf(frames, "followUps")).toHaveLength(0);
|
||||
const asking = framesOf<{ activity: CoachActivity }>(
|
||||
frames,
|
||||
"activity",
|
||||
).find((f) => f.activity.phase === "asking");
|
||||
expect(asking?.activity.label).toBe("Asking you…");
|
||||
});
|
||||
});
|
||||
|
||||
describe("concurrent turns", () => {
|
||||
it("refuses a third turn at once and gives its slot back", async () => {
|
||||
m.checkRateLimit.mockImplementation(async (key: string) =>
|
||||
key.startsWith("coach-turn-active:")
|
||||
? { allowed: false, resetAt: 0 }
|
||||
: { allowed: true, resetAt: 0 },
|
||||
);
|
||||
const { status } = await h.postTurn(post, {
|
||||
message: "How was my blood pressure?",
|
||||
locale: "en",
|
||||
});
|
||||
expect(status).toBe(429);
|
||||
expect(providerCalls).toHaveLength(0);
|
||||
expect(m.checkRateLimit).toHaveBeenCalledWith(
|
||||
"coach-turn-active:u1",
|
||||
2,
|
||||
// The longest turn's wall time (200 s since v1.41.2) plus 30 s for
|
||||
// its reply to stream.
|
||||
230_000,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a tapped decision", () => {
|
||||
beforeEach(() => {
|
||||
world.priorTurns = [
|
||||
{ role: "user", content: "I started a weekly injection in May." },
|
||||
{
|
||||
role: "assistant",
|
||||
content: "Noted. Shall I remember that you take a weekly injection?",
|
||||
},
|
||||
];
|
||||
world.storedProvenance = {
|
||||
memoryNote: { proposal: true, proposalId: "p1", category: "medication" },
|
||||
};
|
||||
});
|
||||
|
||||
it("saves through the contract and answers with the catalog line, no model", async () => {
|
||||
contract.decideFactProposal.mockResolvedValue({
|
||||
kind: "saved",
|
||||
factId: "f1",
|
||||
});
|
||||
const { frames } = await h.postTurn(post, {
|
||||
message: "Yes, remember it",
|
||||
locale: "en",
|
||||
conversationId: h.CONVERSATION_ID,
|
||||
memoryDecision: {
|
||||
messageId: h.LAST_ASSISTANT_ID,
|
||||
proposalId: "p1",
|
||||
accept: true,
|
||||
},
|
||||
});
|
||||
expect(providerCalls).toHaveLength(0);
|
||||
expect(contract.decideFactProposal).toHaveBeenCalledWith({
|
||||
userId: "u1",
|
||||
conversationId: h.CONVERSATION_ID,
|
||||
messageId: h.LAST_ASSISTANT_ID,
|
||||
proposalId: "p1",
|
||||
accept: true,
|
||||
});
|
||||
const prose = framesOf<{ token: string }>(frames, "token")
|
||||
.map((f) => f.token)
|
||||
.join("");
|
||||
expect(prose).toBe("Got it, I'll remember.");
|
||||
expect(m.appendMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ role: "assistant", providerType: "decision" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("goes to the model with the tap's label when the proposal is not on the latest reply", async () => {
|
||||
world.script = [{ text: "Sure." }];
|
||||
const { frames } = await h.postTurn(post, {
|
||||
message: "Yes, remember it",
|
||||
locale: "en",
|
||||
conversationId: h.CONVERSATION_ID,
|
||||
memoryDecision: { messageId: "m-older", proposalId: "p1", accept: true },
|
||||
});
|
||||
expect(contract.decideFactProposal).not.toHaveBeenCalled();
|
||||
expect(providerCalls).toHaveLength(1);
|
||||
expect(frames.at(-1)?.type).toBe("done");
|
||||
});
|
||||
});
|
||||
@@ -54,6 +54,7 @@ vi.mock("@/lib/db", () => ({
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
refundRateLimit: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/lib/i18n/server-locale", () => ({
|
||||
resolveServerLocale: vi.fn(async () => "en"),
|
||||
|
||||
@@ -52,7 +52,10 @@ vi.mock("@/lib/api-response", () => ({
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
|
||||
vi.mock("@/lib/db", () => ({ prisma: {} }));
|
||||
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
refundRateLimit: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/lib/i18n/server-locale", () => ({
|
||||
resolveServerLocale: vi.fn(async () => "en"),
|
||||
}));
|
||||
|
||||
@@ -49,6 +49,7 @@ vi.mock("@/lib/db", () => ({
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
refundRateLimit: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/lib/i18n/server-locale", () => ({
|
||||
resolveServerLocale: vi.fn(async () => "en"),
|
||||
|
||||
@@ -69,7 +69,10 @@ vi.mock("@/lib/db", () => ({
|
||||
const { checkRateLimit } = vi.hoisted(() => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit }));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit,
|
||||
refundRateLimit: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/lib/i18n/server-locale", () => ({
|
||||
resolveServerLocale: vi.fn(async () => "en"),
|
||||
}));
|
||||
|
||||
@@ -62,7 +62,10 @@ vi.mock("@/lib/db", () => ({
|
||||
const { checkRateLimit } = vi.hoisted(() => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit }));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit,
|
||||
refundRateLimit: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/lib/i18n/server-locale", () => ({
|
||||
resolveServerLocale: vi.fn(async () => "en"),
|
||||
}));
|
||||
|
||||
@@ -53,7 +53,10 @@ vi.mock("@/lib/db", () => ({
|
||||
const { checkRateLimit } = vi.hoisted(() => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit }));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit,
|
||||
refundRateLimit: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/lib/i18n/server-locale", () => ({
|
||||
resolveServerLocale: vi.fn(async () => "en"),
|
||||
}));
|
||||
|
||||
@@ -38,7 +38,10 @@ vi.mock("@/lib/api-response", () => ({
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
|
||||
vi.mock("@/lib/db", () => ({ prisma: {} }));
|
||||
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
refundRateLimit: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/lib/i18n/server-locale", () => ({
|
||||
resolveServerLocale: vi.fn(async () => "en"),
|
||||
}));
|
||||
|
||||
@@ -51,6 +51,7 @@ vi.mock("@/lib/db", () => ({
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
refundRateLimit: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/lib/i18n/server-locale", () => ({
|
||||
resolveServerLocale: vi.fn(async () => "en"),
|
||||
|
||||
@@ -70,7 +70,10 @@ vi.mock("@/lib/db", () => ({
|
||||
const { checkRateLimit } = vi.hoisted(() => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit }));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit,
|
||||
refundRateLimit: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/lib/i18n/server-locale", () => ({
|
||||
resolveServerLocale: vi.fn(async () => "en"),
|
||||
}));
|
||||
@@ -316,21 +319,25 @@ describe("coach chat — tool-mode routing (F1)", () => {
|
||||
expect(args.messages[0].content).toContain("2400");
|
||||
});
|
||||
|
||||
it("reserves the multi-round budget in tool mode", async () => {
|
||||
it("reserves round one and the final answer up front in tool mode", async () => {
|
||||
resolveProviderChain.mockResolvedValue([
|
||||
{ providerType: "anthropic", instance: {} },
|
||||
]);
|
||||
await post(chatReq({ message: "How is my BP?" }));
|
||||
// maxTokens (1500) × MAX_ROUNDS (3). The 4th arg is the provider-aware
|
||||
// daily cap (F1) — mocked to the user-plan ceiling for this BYOK chain.
|
||||
// v1.41 — the first round (its input estimate plus a round's answer and
|
||||
// the default medium thinking budget) and the final answer's reserve, in
|
||||
// one reservation. The 4th arg is the provider-aware daily cap —
|
||||
// the user-plan ceiling for this BYOK chain.
|
||||
expect(reserveBudget).toHaveBeenCalledWith(
|
||||
"u1",
|
||||
4500,
|
||||
expect.any(Number),
|
||||
"2026-06-21",
|
||||
2_000_000,
|
||||
"user",
|
||||
"coach",
|
||||
);
|
||||
const reserved = (reserveBudget.mock.calls[0] as unknown[])[1] as number;
|
||||
expect(reserved).toBeGreaterThanOrEqual(600 + 4_096 + 1_200);
|
||||
});
|
||||
|
||||
it("surfaces a graceful provider-error stream (NOT an HTTP 500) when the tool loop throws a tagged provider error", async () => {
|
||||
|
||||
@@ -132,6 +132,7 @@ vi.mock("@/lib/workouts/sport-context", () => ({
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
refundRateLimit: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/lib/i18n/server-locale", () => ({
|
||||
resolveServerLocale: vi.fn(async () => "en"),
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { checkRateLimit, refundRateLimit } from "@/lib/rate-limit";
|
||||
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
|
||||
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
@@ -37,7 +37,10 @@ import { coachChatRequestSchema } from "@/lib/ai/coach/types";
|
||||
import { listConversations } from "@/lib/ai/coach/persistence";
|
||||
import { detectRefusal } from "@/lib/ai/coach/refusal";
|
||||
import { runCoachTurn } from "@/lib/ai/coach/turn/pipeline";
|
||||
import type { ReasoningLevel } from "@/lib/ai/reasoning/levels";
|
||||
import { resolveCoachTurnReasoningLevel } from "@/lib/ai/reasoning/controls";
|
||||
import { streamProviderError, streamRefusal } from "@/lib/ai/coach/turn/sse";
|
||||
import { TURN_LIMITS } from "@/lib/ai/coach/tools/turn-budget";
|
||||
|
||||
/**
|
||||
* The `coach` capability gate for a turn. Every unavailable reason throws the
|
||||
@@ -63,6 +66,20 @@ async function coachCapabilityRefusal(): Promise<Response | null> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.41 — at most this many Coach turns of one person at once. A budgeted
|
||||
* turn can run for minutes; three tabs firing at the same time would run
|
||||
* three of them against one daily budget and one provider account.
|
||||
*/
|
||||
const COACH_CONCURRENT_TURNS = 2;
|
||||
/**
|
||||
* How long a turn's slot is held at most: the longest turn's wall time
|
||||
* (`TURN_LIMITS.user.wallMs`, 200 s) plus 30 s for the streaming of its
|
||||
* reply. A slot whose release never arrived (a crashed process) frees itself
|
||||
* when the window ends.
|
||||
*/
|
||||
const COACH_TURN_SLOT_MS = TURN_LIMITS.user.wallMs + 30_000;
|
||||
|
||||
async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
const auth = await requireAuth();
|
||||
// The `coach` capability, right after auth and before the rate limit and
|
||||
@@ -111,6 +128,8 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
workoutId,
|
||||
followUp,
|
||||
clarification,
|
||||
memoryDecision,
|
||||
planDecision,
|
||||
} = parsed.data;
|
||||
|
||||
// Per-user request-rate ceiling layered in front of the daily budget
|
||||
@@ -162,6 +181,38 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
});
|
||||
}
|
||||
|
||||
// v1.41 — the person's effective reasoning level: their choice after the
|
||||
// operator's switch and highest level, from the shared resolver. Who pays
|
||||
// is not known until the turn resolves its chain, so the turn applies the
|
||||
// operator-paid ceiling itself (`turn/reasoning.ts`); the provider's own
|
||||
// ability is left to its client.
|
||||
const reasoningLevel: ReasoningLevel = await resolveCoachTurnReasoningLevel(
|
||||
auth.user.coachPrefsJson,
|
||||
);
|
||||
|
||||
// v1.41 — the person's turn slot. The counter is the rate-limit row's own
|
||||
// (an atomic upsert), given back when the turn is over; a refused slot is
|
||||
// given back at once, since the refused attempt never ran.
|
||||
const slotKey = `coach-turn-active:${userId}`;
|
||||
const slot = await checkRateLimit(
|
||||
slotKey,
|
||||
COACH_CONCURRENT_TURNS,
|
||||
COACH_TURN_SLOT_MS,
|
||||
);
|
||||
const releaseSlot = () => {
|
||||
void refundRateLimit(slotKey).catch(() => {
|
||||
// The window frees the slot on its own.
|
||||
});
|
||||
};
|
||||
if (!slot.allowed) {
|
||||
releaseSlot();
|
||||
annotate({
|
||||
action: { name: "insights.coach.concurrent-limited" },
|
||||
meta: { limit: COACH_CONCURRENT_TURNS },
|
||||
});
|
||||
return apiError("Too many Coach requests, please wait a moment", 429);
|
||||
}
|
||||
|
||||
// Conversation, context, chain, egress re-check, budget, and the streamed
|
||||
// reply: `src/lib/ai/coach/turn/`.
|
||||
return runCoachTurn({
|
||||
@@ -175,6 +226,10 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
workoutId,
|
||||
followUp,
|
||||
clarification,
|
||||
memoryDecision,
|
||||
planDecision,
|
||||
reasoningLevel,
|
||||
releaseSlot,
|
||||
recheckCapability: coachCapabilityRefusal,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,9 +13,17 @@ import { NextRequest } from "next/server";
|
||||
*/
|
||||
|
||||
const buildCoachReadStrip = vi.fn();
|
||||
const zoneSeen = vi.fn();
|
||||
vi.mock("@/lib/insights/derived/coach-read", () => ({
|
||||
buildCoachReadStrip: (userId: string, metric: string, locale: string) =>
|
||||
buildCoachReadStrip(userId, metric, locale),
|
||||
buildCoachReadStrip: (
|
||||
userId: string,
|
||||
metric: string,
|
||||
locale: string,
|
||||
opts: { tz: string },
|
||||
) => {
|
||||
zoneSeen(opts.tz);
|
||||
return buildCoachReadStrip(userId, metric, locale);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
@@ -58,7 +66,7 @@ import { GET } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
function sessionWithLocale(locale: string | null) {
|
||||
function sessionWithLocale(locale: string | null, timezone?: string) {
|
||||
return {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
@@ -66,6 +74,7 @@ function sessionWithLocale(locale: string | null) {
|
||||
username: "testuser",
|
||||
role: "USER" as const,
|
||||
locale,
|
||||
...(timezone ? { timezone } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -126,3 +135,19 @@ describe("GET /api/insights/coach-read — the reader's language", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/insights/coach-read — the reader's day", () => {
|
||||
it("passes the reader's own zone, which decides whether a day is still in progress", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(
|
||||
sessionWithLocale(null, "America/New_York") as unknown as Awaited<
|
||||
ReturnType<typeof getSession>
|
||||
>,
|
||||
);
|
||||
zoneSeen.mockReset();
|
||||
|
||||
const res = await GET(REQUEST());
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(zoneSeen).toHaveBeenCalledWith("America/New_York");
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,7 @@ import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
import { measurementTypeEnum } from "@/lib/validations/measurement";
|
||||
import { buildCoachReadStrip } from "@/lib/insights/derived/coach-read";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { resolveStoredTimezone } from "@/lib/tz/resolver";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -66,7 +67,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// English, one line under the other.
|
||||
const locale = await resolveServerLocale({ userLocale: user.locale ?? null });
|
||||
|
||||
const strip = await buildCoachReadStrip(user.id, metric, locale);
|
||||
// The reader's zone decides which day is "today": a glucose day still in
|
||||
// progress is placed against the same hours of the earlier days.
|
||||
const strip = await buildCoachReadStrip(user.id, metric, locale, {
|
||||
tz: await resolveStoredTimezone(user.timezone),
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "insights.coach-read" },
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
/**
|
||||
* PATCH /api/insights/coach/facts/[id] — edit one Coach fact's wording.
|
||||
* DELETE /api/insights/coach/facts/[id] — soft-delete one Coach fact.
|
||||
*
|
||||
* v1.11.1 — the per-fact "forget this one thing" delete for the durable
|
||||
* Coach facts surface.
|
||||
* Coach facts surface. It is also the "Undo" under a note the Coach saved in
|
||||
* a turn (v1.41).
|
||||
*
|
||||
* v1.41 — PATCH rewrites the fact's text (re-encrypted) and reads the new
|
||||
* wording again (`settleCategory`, the lexicon the Coach's own saves pass):
|
||||
* text that now names a medication moves to `medication`, so the medications
|
||||
* module still filters it; other health wording moves a non-health category
|
||||
* to `condition`; otherwise the category stays. A health fact the person
|
||||
* wrote themselves is confirmed by that edit (`source: "user"`). Only a
|
||||
* listed fact can be edited: a proposal still waiting for the person's
|
||||
* answer is not one, and answers 404 like an unknown id. Editing is the
|
||||
* person's own data, so no Coach or AI gate.
|
||||
*
|
||||
* Ownership + existence privacy: the soft-delete uses `updateMany` scoped
|
||||
* `where: { id, userId, deletedAt: null }` rather than `update`. A
|
||||
@@ -18,14 +30,109 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { isHealthFact } from "@/lib/ai/coach/facts";
|
||||
import { settleCategory } from "@/lib/ai/coach/memory/remember";
|
||||
import { PROPOSED_FACT_SOURCE } from "@/lib/ai/coach/memory/shared";
|
||||
import {
|
||||
COACH_MEMORY_CATEGORIES,
|
||||
type CoachMemoryCategory,
|
||||
} from "@/lib/ai/coach/types";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { coachFactPatchSchema } from "@/lib/validations/coach-fact";
|
||||
|
||||
interface RouteCtx {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
const PATCH_RATE_LIMIT = 40;
|
||||
const PATCH_WINDOW_MS = 60_000;
|
||||
|
||||
export const PATCH = apiHandler(async (req: NextRequest, ctx: RouteCtx) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`coach-facts:patch:${user.id}`,
|
||||
PATCH_RATE_LIMIT,
|
||||
PATCH_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
const response = apiError("Too many requests", 429);
|
||||
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
|
||||
response.headers.set(k, v);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
const { id } = await ctx.params;
|
||||
const { data: body, error: jsonError } = await safeJson(req, {
|
||||
maxBytes: 2 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = coachFactPatchSchema.safeParse(body);
|
||||
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
|
||||
|
||||
const text = parsed.data.fact.replace(/\s+/g, " ");
|
||||
const live = {
|
||||
id,
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
source: { not: PROPOSED_FACT_SOURCE },
|
||||
};
|
||||
const current = await prisma.coachFact.findFirst({
|
||||
where: live,
|
||||
select: { category: true, source: true },
|
||||
});
|
||||
if (!current) return apiError("Fact not found", 404);
|
||||
const known = (COACH_MEMORY_CATEGORIES as readonly string[]).includes(
|
||||
current.category,
|
||||
);
|
||||
const category = known
|
||||
? settleCategory(current.category as CoachMemoryCategory, text)
|
||||
: current.category;
|
||||
// A health fact the person wrote is one they confirmed.
|
||||
const source = isHealthFact(category, text) ? "user" : current.source;
|
||||
// `updateMany` scoped by id, owner and liveness: an unknown, cross-user,
|
||||
// deleted or still-proposed id is a 0-count no-op, never a P2025 throw.
|
||||
const { count } = await prisma.coachFact.updateMany({
|
||||
where: live,
|
||||
data: { factEncrypted: encryptToBytes(text), category, source },
|
||||
});
|
||||
if (count === 0) {
|
||||
// Indistinguishable from "owned by someone else" — never reveal which.
|
||||
return apiError("Fact not found", 404);
|
||||
}
|
||||
|
||||
const row = await prisma.coachFact.findFirst({
|
||||
where: { id, userId: user.id, deletedAt: null },
|
||||
select: { id: true, category: true, source: true, updatedAt: true },
|
||||
});
|
||||
if (!row) return apiError("Fact not found", 404);
|
||||
|
||||
annotate({
|
||||
action: { name: "coach.facts.updated" },
|
||||
meta: { category: row.category },
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
fact: {
|
||||
id: row.id,
|
||||
category: row.category,
|
||||
text,
|
||||
source: row.source,
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (_request: NextRequest, ctx: RouteCtx) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* v1.41 — the memory list's write half: the remember button (POST), editing
|
||||
* a fact (PATCH), and a GET that never lists a proposal still waiting for
|
||||
* the person's answer.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
coachFact: {
|
||||
findMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/ai/coach/memory/remember", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/ai/coach/memory/remember")>()),
|
||||
rememberMessageAsFact: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({
|
||||
allowed: true,
|
||||
remaining: 10,
|
||||
resetAt: new Date(),
|
||||
})),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({
|
||||
getSession: vi.fn(),
|
||||
}));
|
||||
|
||||
// The capability gate answers "unavailable" throughout: every verb must
|
||||
// still work, and must not even ask.
|
||||
vi.mock("@/lib/ai/capabilities/gate", () => ({
|
||||
requireAiCapability: vi.fn(async () => {
|
||||
throw new Error("the coach capability was consulted");
|
||||
}),
|
||||
getAiCapability: vi.fn(async () => ({
|
||||
available: false,
|
||||
reason: "operator_disabled",
|
||||
onDeviceAllowed: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock the codec so the test never needs an encryption key. The "row"
|
||||
// carries a tagged Uint8Array and the mock maps it back to a string;
|
||||
// a sentinel buffer triggers a throw to exercise the fail-closed skip.
|
||||
vi.mock("@/lib/ai/coach/bytes-codec", () => ({
|
||||
decryptFromBytes: vi.fn((buf: Uint8Array) => Buffer.from(buf).toString()),
|
||||
encryptToBytes: vi.fn((s: string) => new Uint8Array(Buffer.from(s))),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({
|
||||
emitIfSampled: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/logging/context")>();
|
||||
return {
|
||||
...actual,
|
||||
annotate: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET, POST } from "../route";
|
||||
import { PATCH } from "../[id]/route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { rememberMessageAsFact } from "@/lib/ai/coach/memory/remember";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "user-1",
|
||||
username: "tester",
|
||||
role: "USER" as const,
|
||||
displayName: null,
|
||||
},
|
||||
};
|
||||
|
||||
const callGet = GET as unknown as () => Promise<Response>;
|
||||
const callPost = POST as unknown as (req: NextRequest) => Promise<Response>;
|
||||
const callPatch = PATCH as unknown as (
|
||||
req: NextRequest,
|
||||
ctx: { params: Promise<{ id: string }> },
|
||||
) => Promise<Response>;
|
||||
|
||||
function req(method: string, body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/insights/coach/facts", {
|
||||
method,
|
||||
body: JSON.stringify(body),
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never);
|
||||
});
|
||||
|
||||
describe("GET /api/insights/coach/facts (v1.41)", () => {
|
||||
it("never lists a proposal and returns where each fact came from", async () => {
|
||||
vi.mocked(prisma.coachFact.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "f1",
|
||||
category: "goal",
|
||||
factEncrypted: new Uint8Array(Buffer.from("75 kg by December")),
|
||||
confidence: 80,
|
||||
source: "coach",
|
||||
sourceConversationId: "c1",
|
||||
sourceMessageId: "m1",
|
||||
lastUsedAt: new Date("2026-10-05T10:00:00Z"),
|
||||
createdAt: new Date("2026-10-01T10:00:00Z"),
|
||||
updatedAt: new Date("2026-10-01T10:00:00Z"),
|
||||
},
|
||||
] as never);
|
||||
const res = await callGet();
|
||||
const body = (await res.json()) as {
|
||||
data: { facts: Array<Record<string, unknown>> };
|
||||
};
|
||||
expect(body.data.facts[0]).toMatchObject({
|
||||
id: "f1",
|
||||
text: "75 kg by December",
|
||||
source: "coach",
|
||||
sourceMessageId: "m1",
|
||||
lastUsedAt: "2026-10-05T10:00:00.000Z",
|
||||
});
|
||||
const where = vi.mocked(prisma.coachFact.findMany).mock.calls[0]?.[0]
|
||||
?.where as Record<string, unknown>;
|
||||
expect(where.source).toEqual({ not: "proposed" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/insights/coach/facts", () => {
|
||||
it("remembers the caller's own message by id", async () => {
|
||||
vi.mocked(rememberMessageAsFact).mockResolvedValue({
|
||||
id: "f9",
|
||||
category: "medication",
|
||||
text: "I take ramipril",
|
||||
source: "user",
|
||||
created: true,
|
||||
});
|
||||
const res = await callPost(req("POST", { messageId: "um1" }));
|
||||
expect(res.status).toBe(201);
|
||||
expect(rememberMessageAsFact).toHaveBeenCalledWith({
|
||||
userId: "user-1",
|
||||
messageId: "um1",
|
||||
});
|
||||
const body = (await res.json()) as { data: Record<string, unknown> };
|
||||
expect(body.data).toEqual({
|
||||
fact: {
|
||||
id: "f9",
|
||||
category: "medication",
|
||||
text: "I take ramipril",
|
||||
source: "user",
|
||||
},
|
||||
created: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("never accepts text or a userId in the body", async () => {
|
||||
const res = await callPost(
|
||||
req("POST", { messageId: "um1", fact: "anything", userId: "x" }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(rememberMessageAsFact).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is a 404 for a message the caller does not own", async () => {
|
||||
vi.mocked(rememberMessageAsFact).mockResolvedValue(null);
|
||||
const res = await callPost(req("POST", { messageId: "foreign" }));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("is refused while the Coach module is off", async () => {
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({
|
||||
enabled: false,
|
||||
response: new Response(null, { status: 403 }),
|
||||
} as never);
|
||||
const res = await callPost(req("POST", { messageId: "um1" }));
|
||||
expect(res.status).toBe(403);
|
||||
expect(rememberMessageAsFact).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/insights/coach/facts/[id]", () => {
|
||||
const ctx = { params: Promise.resolve({ id: "f1" }) };
|
||||
|
||||
it("rewrites the text, re-encrypted, scoped to the caller's live facts", async () => {
|
||||
vi.mocked(prisma.coachFact.updateMany).mockResolvedValue({
|
||||
count: 1,
|
||||
} as never);
|
||||
vi.mocked(prisma.coachFact.findFirst).mockResolvedValue({
|
||||
id: "f1",
|
||||
category: "goal",
|
||||
source: "coach",
|
||||
updatedAt: new Date("2026-10-06T10:00:00Z"),
|
||||
} as never);
|
||||
const res = await callPatch(
|
||||
req("PATCH", { fact: " Wants 74 kg by December " }),
|
||||
ctx,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const arg = vi.mocked(prisma.coachFact.updateMany).mock.calls[0]?.[0] as {
|
||||
where: Record<string, unknown>;
|
||||
data: { factEncrypted: Uint8Array };
|
||||
};
|
||||
expect(arg.where).toEqual({
|
||||
id: "f1",
|
||||
userId: "user-1",
|
||||
deletedAt: null,
|
||||
source: { not: "proposed" },
|
||||
});
|
||||
expect(Buffer.from(arg.data.factEncrypted).toString()).toBe(
|
||||
"Wants 74 kg by December",
|
||||
);
|
||||
// Not health: the category and the source stay.
|
||||
expect(arg.data).toMatchObject({ category: "goal", source: "coach" });
|
||||
expect(Object.keys(arg.data).sort()).toEqual(
|
||||
["category", "factEncrypted", "source"].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
// Health text under a non-health category: moved, and confirmed.
|
||||
["preference", "coach", "Takes metformin with breakfast", "medication"],
|
||||
["context", "extracted", "Has asthma since childhood", "condition"],
|
||||
// A medication named under another health category still moves, so the
|
||||
// medications module keeps filtering it.
|
||||
["condition", "user", "Asthma, uses a salbutamol inhaler", "medication"],
|
||||
// A health category the new wording no longer reads as: kept.
|
||||
["constraint", "coach", "No running before 7", "constraint"],
|
||||
])(
|
||||
"re-reads a %s fact (%s) edited to %j as %s",
|
||||
async (category, source, fact, expected) => {
|
||||
vi.mocked(prisma.coachFact.updateMany).mockResolvedValue({
|
||||
count: 1,
|
||||
} as never);
|
||||
vi.mocked(prisma.coachFact.findFirst).mockResolvedValue({
|
||||
id: "f1",
|
||||
category,
|
||||
source,
|
||||
updatedAt: new Date("2026-10-06T10:00:00Z"),
|
||||
} as never);
|
||||
const res = await callPatch(req("PATCH", { fact }), ctx);
|
||||
expect(res.status).toBe(200);
|
||||
const arg = vi.mocked(prisma.coachFact.updateMany).mock.calls[0]?.[0] as {
|
||||
data: { category: string; source: string };
|
||||
};
|
||||
expect(arg.data.category).toBe(expected);
|
||||
// A health fact the person wrote themselves is one they confirmed.
|
||||
expect(arg.data.source).toBe("user");
|
||||
},
|
||||
);
|
||||
|
||||
it("is a 404 for an unknown, foreign or proposed id", async () => {
|
||||
vi.mocked(prisma.coachFact.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.coachFact.updateMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
const res = await callPatch(req("PATCH", { fact: "Something new" }), ctx);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("refuses an empty or over-long fact, or extra keys", async () => {
|
||||
for (const body of [
|
||||
{ fact: "" },
|
||||
{ fact: "x".repeat(161) },
|
||||
{ fact: "fine text", category: "condition" },
|
||||
]) {
|
||||
const res = await callPatch(req("PATCH", body), ctx);
|
||||
expect(res.status).toBe(422);
|
||||
}
|
||||
expect(prisma.coachFact.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,23 @@
|
||||
/**
|
||||
* GET /api/insights/coach/facts — list the caller's ACTIVE Coach facts.
|
||||
* POST /api/insights/coach/facts — remember one of the caller's own Coach
|
||||
* messages (the remember button).
|
||||
* DELETE /api/insights/coach/facts — "forget all": soft-delete every active
|
||||
* fact for the caller.
|
||||
*
|
||||
* v1.11.1 — the GDPR / "forget what you know about me" surface for the
|
||||
* durable Coach facts the assistant extracts during conversations. Facts
|
||||
* are server-extracted, not user-authored, so this surface is read +
|
||||
* delete only (no POST / PATCH).
|
||||
* durable Coach facts. v1.41 — also the Coach's one memory list: each fact
|
||||
* says where it came from (`source`), when it was last used in a turn and
|
||||
* which message it came out of; the person edits a fact through
|
||||
* `PATCH …/facts/{id}`. A health fact still waiting for the person's answer
|
||||
* (`source: "proposed"`) is not listed: nothing the person has not agreed to
|
||||
* appears as something the Coach knows. "Forget all" clears those too.
|
||||
*
|
||||
* POST names a message, never text: the server reads the caller's own stored
|
||||
* message, files it with the medication / condition lexicon (anything else is
|
||||
* `context`) and saves it as `source: "user"`. The tap is the person's
|
||||
* confirmation, so a health message is saved too. A message the caller does
|
||||
* not own, or an assistant message, is a 404.
|
||||
*
|
||||
* Ownership: every query is scoped `where: { userId, ... }`, so a caller
|
||||
* can only ever see or clear their own facts. The fact text is decrypted
|
||||
@@ -20,16 +31,35 @@
|
||||
* Erasure that depends on the feature being on is no erasure.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { rememberMessageAsFact } from "@/lib/ai/coach/memory/remember";
|
||||
import { PROPOSED_FACT_SOURCE } from "@/lib/ai/coach/memory/shared";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { coachFactCreateSchema } from "@/lib/validations/coach-fact";
|
||||
|
||||
// The remember button is one tap per message; the limit only caps a runaway
|
||||
// client loop, like the plan mutations.
|
||||
const CREATE_RATE_LIMIT = 40;
|
||||
const CREATE_WINDOW_MS = 60_000;
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rows = await prisma.coachFact.findMany({
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
source: { not: PROPOSED_FACT_SOURCE },
|
||||
},
|
||||
// Highest-confidence first, then newest — mirrors the injection
|
||||
// ordering so the management list reads in the same priority the
|
||||
// assistant actually weights the facts.
|
||||
@@ -39,7 +69,12 @@ export const GET = apiHandler(async () => {
|
||||
category: true,
|
||||
factEncrypted: true,
|
||||
confidence: true,
|
||||
source: true,
|
||||
sourceConversationId: true,
|
||||
sourceMessageId: true,
|
||||
lastUsedAt: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -48,7 +83,12 @@ export const GET = apiHandler(async () => {
|
||||
category: string;
|
||||
text: string;
|
||||
confidence: number;
|
||||
source: string;
|
||||
sourceConversationId: string | null;
|
||||
sourceMessageId: string | null;
|
||||
lastUsedAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}> = [];
|
||||
|
||||
for (const row of rows) {
|
||||
@@ -65,7 +105,12 @@ export const GET = apiHandler(async () => {
|
||||
category: row.category,
|
||||
text,
|
||||
confidence: row.confidence,
|
||||
source: row.source,
|
||||
sourceConversationId: row.sourceConversationId,
|
||||
sourceMessageId: row.sourceMessageId,
|
||||
lastUsedAt: row.lastUsedAt,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -77,6 +122,61 @@ export const GET = apiHandler(async () => {
|
||||
return apiSuccess({ facts });
|
||||
});
|
||||
|
||||
export const POST = apiHandler(async (req: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
// Saving to the Coach's memory is Coach use, like confirming a plan; it is
|
||||
// not model work, so no AI gate.
|
||||
const gate = await requireModuleEnabled(user.id, "coach");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`coach-facts:create:${user.id}`,
|
||||
CREATE_RATE_LIMIT,
|
||||
CREATE_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
const response = apiError("Too many requests", 429);
|
||||
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
|
||||
response.headers.set(k, v);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(req, {
|
||||
maxBytes: 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = coachFactCreateSchema.safeParse(body);
|
||||
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
|
||||
|
||||
const remembered = await rememberMessageAsFact({
|
||||
userId: user.id,
|
||||
messageId: parsed.data.messageId,
|
||||
});
|
||||
if (!remembered) {
|
||||
// Indistinguishable from "owned by someone else" — never reveal which.
|
||||
return apiError("Message not found", 404);
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "coach.facts.created" },
|
||||
meta: { category: remembered.category, created: remembered.created },
|
||||
});
|
||||
|
||||
return apiSuccess(
|
||||
{
|
||||
fact: {
|
||||
id: remembered.id,
|
||||
category: remembered.category,
|
||||
text: remembered.text,
|
||||
source: remembered.source,
|
||||
},
|
||||
created: remembered.created,
|
||||
},
|
||||
remembered.created ? 201 : 200,
|
||||
);
|
||||
});
|
||||
|
||||
export const DELETE = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
|
||||
@@ -49,6 +49,8 @@ vi.mock("@/lib/db", () => ({
|
||||
})),
|
||||
updateMany: vi.fn(async () => ({ count: 1 })),
|
||||
},
|
||||
// The newest reading per signal type (`readSignalLastSeen`).
|
||||
measurement: { groupBy: vi.fn(async () => []) },
|
||||
auditLog: {
|
||||
// v1.4.16 A7: route now evicts stale per-status cache rows
|
||||
// (`insights.<scope>-status.<locale>`) on every successful
|
||||
@@ -593,6 +595,7 @@ describe("POST /api/insights/generate — cache write (v1.16.8)", () => {
|
||||
insightsCachedText: JSON.stringify({
|
||||
changed: "still fresh",
|
||||
dailyBriefing: { paragraph: "ok", keyFindings: [] },
|
||||
briefingGeneratedAt: new Date().toISOString(),
|
||||
}),
|
||||
locale: "en",
|
||||
} as never);
|
||||
@@ -632,6 +635,7 @@ describe("POST /api/insights/generate — cache write (v1.16.8)", () => {
|
||||
insightsCachedText: JSON.stringify({
|
||||
changed: "still fresh",
|
||||
dailyBriefing: { paragraph: "ok", keyFindings: [] },
|
||||
briefingGeneratedAt: new Date().toISOString(),
|
||||
}),
|
||||
locale: "en",
|
||||
} as never);
|
||||
@@ -680,6 +684,7 @@ describe("POST /api/insights/generate — briefingless fresh cache (v1.28.30)",
|
||||
insightsCachedAt: new Date(Date.now() - 6 * 60 * 60 * 1000),
|
||||
insightsCachedText: JSON.stringify({
|
||||
dailyBriefing: { paragraph: "today", keyFindings: [] },
|
||||
briefingGeneratedAt: new Date().toISOString(),
|
||||
}),
|
||||
locale: "en",
|
||||
} as never);
|
||||
@@ -802,7 +807,10 @@ describe("POST /api/insights/generate — payload-size hard downgrade (H1)", ()
|
||||
|
||||
describe("GET /api/insights/generate — read-only advisor read", () => {
|
||||
it("serves the cached briefing without ever calling the provider", async () => {
|
||||
const cached = { dailyBriefing: { paragraph: "ok", keyFindings: [] } };
|
||||
const cached = {
|
||||
dailyBriefing: { paragraph: "ok", keyFindings: [] },
|
||||
briefingGeneratedAt: new Date().toISOString(),
|
||||
};
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
insightsCachedAt: new Date(),
|
||||
insightsCachedText: JSON.stringify(cached),
|
||||
@@ -878,7 +886,10 @@ describe("GET /api/insights/generate — read-only advisor read", () => {
|
||||
});
|
||||
|
||||
it("serves a fresh cache whose tag matches the reader's language", async () => {
|
||||
const cached = { dailyBriefing: { paragraph: "English", keyFindings: [] } };
|
||||
const cached = {
|
||||
dailyBriefing: { paragraph: "English", keyFindings: [] },
|
||||
briefingGeneratedAt: new Date().toISOString(),
|
||||
};
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
insightsCachedAt: new Date(),
|
||||
insightsCachedText: JSON.stringify(cached),
|
||||
@@ -898,6 +909,87 @@ describe("GET /api/insights/generate — read-only advisor read", () => {
|
||||
expect(enqueueForceWarm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("treats yesterday's text that a warm only re-stamped as stale, and asks for a warm", async () => {
|
||||
// Stamped a minute ago, generated yesterday in the reader's zone.
|
||||
const yesterday = new Date(Date.now() - 30 * 60 * 60 * 1000);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
insightsCachedAt: new Date(Date.now() - 60_000),
|
||||
insightsCachedText: JSON.stringify({
|
||||
dailyBriefing: { paragraph: "Yesterday's read.", keyFindings: [] },
|
||||
briefingGeneratedAt: yesterday.toISOString(),
|
||||
}),
|
||||
insightsCachedLocale: "en",
|
||||
locale: "en",
|
||||
timezone: "UTC",
|
||||
} as never);
|
||||
|
||||
const res = await (GET as unknown as (req: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/insights/generate"),
|
||||
);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
insights: { dailyBriefing: unknown };
|
||||
revalidating: boolean;
|
||||
};
|
||||
};
|
||||
expect(body.data.insights.dailyBriefing).toBeNull();
|
||||
expect(body.data.revalidating).toBe(true);
|
||||
expect(enqueueForceWarm).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("serves today's signals with deltas at display precision", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
insightsCachedAt: new Date(),
|
||||
insightsCachedText: JSON.stringify({
|
||||
dailyBriefing: {
|
||||
paragraph: "ok",
|
||||
keyFindings: [],
|
||||
signalsOfDay: [
|
||||
{
|
||||
sourceMetric: "sleep",
|
||||
tone: "info",
|
||||
headline: "Shorter night",
|
||||
nudge: "An earlier night.",
|
||||
delta: null,
|
||||
},
|
||||
{
|
||||
sourceMetric: "pulse",
|
||||
tone: "watch",
|
||||
headline: "Pulse up",
|
||||
nudge: "Rest.",
|
||||
delta: "+33.72 bpm vs your 30-day average",
|
||||
},
|
||||
],
|
||||
},
|
||||
briefingGeneratedAt: new Date().toISOString(),
|
||||
}),
|
||||
insightsCachedLocale: "en",
|
||||
locale: "en",
|
||||
timezone: "UTC",
|
||||
} as never);
|
||||
// The pulse was measured yesterday: its signal is not today's.
|
||||
vi.mocked(prisma.measurement.groupBy).mockResolvedValueOnce([
|
||||
{
|
||||
type: "PULSE",
|
||||
_max: { measuredAt: new Date(Date.now() - 36 * 60 * 60 * 1000) },
|
||||
},
|
||||
] as never);
|
||||
|
||||
const res = await (GET as unknown as (req: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/insights/generate"),
|
||||
);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
insights: {
|
||||
dailyBriefing: { signalsOfDay: Array<{ sourceMetric: string }> };
|
||||
};
|
||||
};
|
||||
};
|
||||
expect(
|
||||
body.data.insights.dailyBriefing.signalsOfDay.map((s) => s.sourceMetric),
|
||||
).toEqual(["sleep"]);
|
||||
});
|
||||
|
||||
it("returns an empty payload (no warm) on a cold cache without a provider", async () => {
|
||||
vi.mocked(hasUsableStatusProvider).mockResolvedValueOnce(false);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
|
||||
@@ -11,6 +11,13 @@
|
||||
* write breaks that convergence — the settling seam tests pin the client
|
||||
* half, this comment is the server half of the contract.
|
||||
*/
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import { readBriefingGeneratedAt } from "@/lib/insights/briefing-generated-at";
|
||||
import {
|
||||
generatedOnLocalToday,
|
||||
insightsPayloadForToday,
|
||||
} from "@/lib/daily/briefing-today-read";
|
||||
import { withBriefingGeneratedAt } from "@/lib/insights/briefing-generated-at";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
@@ -167,6 +174,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
insightsCachedText: true,
|
||||
insightsCachedLocale: true,
|
||||
locale: true,
|
||||
timezone: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -193,9 +201,19 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
dbUser.insightsCachedLocale !== readerLocale;
|
||||
|
||||
const cachedAt = dbUser?.insightsCachedAt ?? null;
|
||||
const timezone = dbUser?.timezone ?? DEFAULT_TIMEZONE;
|
||||
// Fresh means written today on the reader's calendar, by the moment the
|
||||
// text was generated: `insightsCachedAt` also moves when an unchanged
|
||||
// warm only re-stamps yesterday's text.
|
||||
const generatedToday = generatedOnLocalToday(
|
||||
readBriefingGeneratedAt(dbUser?.insightsCachedText ?? null),
|
||||
timezone,
|
||||
new Date(),
|
||||
);
|
||||
const isFresh =
|
||||
!languageMismatch &&
|
||||
cachedAt !== null &&
|
||||
generatedToday &&
|
||||
Date.now() - cachedAt.getTime() < BRIEFING_FRESH_MS;
|
||||
|
||||
// v1.18.9 (#4) — resolve provider availability once, up front. The
|
||||
@@ -253,7 +271,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
meta: { cached: true, legacyPayload, revalidating, hasProvider },
|
||||
});
|
||||
return apiSuccess({
|
||||
insights: cached,
|
||||
// The briefing as every surface serves it: today's text only, its
|
||||
// signals measured today, its deltas at display precision.
|
||||
insights: await insightsPayloadForToday(cached, {
|
||||
userId,
|
||||
timezone,
|
||||
language: readerLocale,
|
||||
}),
|
||||
cached: true,
|
||||
cachedAt,
|
||||
legacyPayload,
|
||||
@@ -328,6 +352,7 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
insightsCachedAt: true,
|
||||
insightsCachedText: true,
|
||||
insightsCachedLocale: true,
|
||||
timezone: true,
|
||||
// v1.4.36 W3 T3 — per-user opt-out list mirroring the Coach
|
||||
// settings. Filtered off `features` before serialisation so the
|
||||
// LLM never sees the excluded blocks.
|
||||
@@ -375,6 +400,7 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
request,
|
||||
userLocale: dbUser?.locale ?? user.locale ?? null,
|
||||
});
|
||||
const postTimezone = dbUser?.timezone ?? DEFAULT_TIMEZONE;
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson<{
|
||||
force?: unknown;
|
||||
@@ -419,9 +445,16 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
// We don't auto-regenerate — that would burn rate-limit tokens
|
||||
// on a cache-hit silently. User-initiated only.
|
||||
const legacyPayload = isLegacyInsightPayload(cached);
|
||||
const carriesBriefing = cachedPayloadCarriesBriefing(
|
||||
dbUser.insightsCachedText,
|
||||
);
|
||||
// A briefing generated on an earlier day is not today's read: like
|
||||
// a briefingless cache, it does not short-circuit a provider-backed
|
||||
// account, and degrades to the cached payload on a 429 below.
|
||||
const carriesBriefing =
|
||||
cachedPayloadCarriesBriefing(dbUser.insightsCachedText) &&
|
||||
generatedOnLocalToday(
|
||||
readBriefingGeneratedAt(dbUser.insightsCachedText),
|
||||
postTimezone,
|
||||
new Date(),
|
||||
);
|
||||
if (carriesBriefing || !(await hasUsableStatusProvider(userId))) {
|
||||
annotate({
|
||||
action: { name: "insights.generate" },
|
||||
@@ -432,7 +465,11 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
},
|
||||
});
|
||||
return apiSuccess({
|
||||
insights: cached,
|
||||
insights: await insightsPayloadForToday(cached, {
|
||||
userId,
|
||||
timezone: postTimezone,
|
||||
language: locale,
|
||||
}),
|
||||
cached: true,
|
||||
cachedAt: dbUser.insightsCachedAt,
|
||||
legacyPayload,
|
||||
@@ -479,7 +516,10 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
meta: { cached: true, rate_limited_fallback: true },
|
||||
});
|
||||
return apiSuccess({
|
||||
insights: briefinglessCacheFallback.cached,
|
||||
insights: await insightsPayloadForToday(
|
||||
briefinglessCacheFallback.cached,
|
||||
{ userId, timezone: postTimezone, language: locale },
|
||||
),
|
||||
cached: true,
|
||||
cachedAt: briefinglessCacheFallback.cachedAt,
|
||||
legacyPayload: briefinglessCacheFallback.legacyPayload,
|
||||
@@ -755,6 +795,9 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
// v1.25 — honours the per-user response-timeout setting.
|
||||
timeoutMs: effectiveTimeoutMs,
|
||||
stage: "generate",
|
||||
// v1.41 — a briefing asked for by hand reasons exactly like the
|
||||
// scheduled one; the retries below repair a reply and do not.
|
||||
reasoningJob: "daily_briefing",
|
||||
});
|
||||
result = fallback.result;
|
||||
workingProviderType = fallback.workingProvider.providerType;
|
||||
@@ -1038,6 +1081,7 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
errorCode: "insights.generate.profileScopeChanged",
|
||||
});
|
||||
}
|
||||
const stored = withBriefingGeneratedAt(insights, new Date());
|
||||
const committed = await prisma.user.updateMany({
|
||||
where: {
|
||||
id: userId,
|
||||
@@ -1051,7 +1095,7 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
},
|
||||
data: {
|
||||
insightsCachedAt: new Date(),
|
||||
insightsCachedText: JSON.stringify(insights),
|
||||
insightsCachedText: JSON.stringify(stored),
|
||||
insightsCachedLocale: locale,
|
||||
insightsSnapshotHash: hashInsightSnapshot({
|
||||
features: compactFeatures,
|
||||
@@ -1131,7 +1175,11 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
insights,
|
||||
insights: await insightsPayloadForToday(stored, {
|
||||
userId,
|
||||
timezone: postTimezone,
|
||||
language: locale,
|
||||
}),
|
||||
cached: false,
|
||||
legacyPayload: false,
|
||||
// v1.28.28 (#470) — additive: non-null when the grounding gate stripped
|
||||
|
||||
+62
-2
@@ -56,6 +56,7 @@
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-input-focus: var(--input-focus);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
@@ -241,7 +242,10 @@
|
||||
--destructive: #ff5555;
|
||||
--border: #44475a;
|
||||
--input: #44475a;
|
||||
--ring: #bd93f9;
|
||||
/* Text-field focus border: a neutral, foreground-based tone, no colour
|
||||
* ring. ~5.2:1 on --background, ~6:1 on --card. */
|
||||
--input-focus: #9a9b9d;
|
||||
--ring: #c4c9d4; /* neutral: no purple focus frames anywhere */
|
||||
--chart-1: #bd93f9;
|
||||
--chart-2: #50fa7b;
|
||||
--chart-3: #ff79c6;
|
||||
@@ -369,7 +373,9 @@
|
||||
--success-foreground: #ffffff;
|
||||
--border: oklch(0.885 0.008 300);
|
||||
--input: oklch(0.87 0.009 300);
|
||||
--ring: oklch(0.6 0.13 292);
|
||||
/* Text-field focus border, neutral: ~4.2:1 on --background. */
|
||||
--input-focus: oklch(0.56 0.012 295);
|
||||
--ring: oklch(0.48 0.012 290); /* neutral: no purple focus frames anywhere */
|
||||
--chart-1: #644ac9;
|
||||
--chart-2: #14720a;
|
||||
--chart-3: #a3134d;
|
||||
@@ -1026,6 +1032,60 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Live status text ────────────────────────────────────────────────
|
||||
* `text-shimmer`: a band of foreground light sweeps through muted text, for a
|
||||
* line that says something is still in progress (the Coach's thinking row).
|
||||
* The gradient is clipped to the glyphs; the band travels left to right once
|
||||
* every two seconds. Under reduced motion the text is plain muted text.
|
||||
*
|
||||
* `waiting-dot`: one of three trailing dots that fade in turn (the caller
|
||||
* staggers them by 0.2 s). Under reduced motion they stand still at full
|
||||
* opacity, which the global backstop below would not give them: it pins the
|
||||
* last frame, and the last frame is dim. */
|
||||
@keyframes text-shimmer {
|
||||
from {
|
||||
background-position: 100% 0;
|
||||
}
|
||||
to {
|
||||
background-position: 0% 0;
|
||||
}
|
||||
}
|
||||
@utility text-shimmer {
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
var(--muted-foreground) 40%,
|
||||
var(--foreground) 50%,
|
||||
var(--muted-foreground) 60%
|
||||
);
|
||||
background-size: 250% 100%;
|
||||
background-repeat: no-repeat;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
animation: text-shimmer 2s linear infinite;
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
animation: none;
|
||||
background-image: none;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
}
|
||||
@keyframes waiting-dot {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@utility waiting-dot {
|
||||
animation: waiting-dot 1.4s ease-in-out infinite;
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
animation: none;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Global reduced-motion backstop ──────────────────────────────────
|
||||
* Defensive catch-all so any animation/transition we forget to gate
|
||||
* per-component still collapses to near-instant when the user has asked
|
||||
|
||||
@@ -14,9 +14,23 @@ import {
|
||||
} from "@/lib/query-keys";
|
||||
import { SettingsToggle } from "./_shared";
|
||||
import { apiGet, apiPut } from "@/lib/api/api-fetch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import {
|
||||
DEFAULT_REASONING_MAX_EFFORT,
|
||||
REASONING_ADMIN_KEYS,
|
||||
REASONING_LEVEL_LABEL_KEYS,
|
||||
REASONING_MAX_EFFORTS,
|
||||
type ReasoningMaxEffort,
|
||||
} from "@/lib/ai/reasoning/levels";
|
||||
|
||||
/**
|
||||
* Operator-side panel for the five assistant switches. The master stops
|
||||
* Operator-side panel for the five assistant switches, and (v1.41) the two
|
||||
* reasoning controls: whether models may think before they answer at all,
|
||||
* and the highest depth a person may choose. Background jobs follow the
|
||||
* same switch; there is no separate one.
|
||||
*
|
||||
* The five switches: The master stops
|
||||
* every AI feature; four sub-switches each stop one cost or egress profile:
|
||||
* the Coach, the daily briefing, status notes (per-reading notes, workout
|
||||
* notes, reaction lines) and reading documents (the vault, lab scans,
|
||||
@@ -48,8 +62,16 @@ interface AssistantFlagsResponse {
|
||||
insightStatus: boolean;
|
||||
documentAi: boolean;
|
||||
};
|
||||
/** v1.41 — the operator's reasoning controls; absent before v1.41. */
|
||||
reasoning?: { enabled: boolean; maxEffort: ReasoningMaxEffort };
|
||||
}
|
||||
|
||||
/** What the route accepts: any of the switches, and the reasoning pair. */
|
||||
type AssistantFlagsPatch = Partial<AssistantFlagsResponse["raw"]> & {
|
||||
aiReasoningEnabled?: boolean;
|
||||
aiReasoningMaxEffort?: ReasoningMaxEffort;
|
||||
};
|
||||
|
||||
function useAssistantFlags() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.adminAssistantFlags(),
|
||||
@@ -66,7 +88,7 @@ function useUpdateAssistantFlags() {
|
||||
const { t } = useTranslations();
|
||||
return useMutation({
|
||||
mutationFn: async (
|
||||
patch: Partial<AssistantFlagsResponse["raw"]>,
|
||||
patch: AssistantFlagsPatch,
|
||||
): Promise<AssistantFlagsResponse> => {
|
||||
return apiPut<AssistantFlagsResponse>(
|
||||
"/api/admin/settings/assistant-flags",
|
||||
@@ -102,6 +124,7 @@ export function AssistantSection() {
|
||||
const raw = data?.raw;
|
||||
const masterOn = raw?.assistantEnabled ?? true;
|
||||
const disabledSubs = !masterOn || mutation.isPending;
|
||||
const reasoningOn = data?.reasoning?.enabled ?? true;
|
||||
|
||||
return (
|
||||
<SettingsCard>
|
||||
@@ -159,6 +182,51 @@ export function AssistantSection() {
|
||||
disabled={disabledSubs}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* v1.41 — reasoning: allowed at all, and how deep at most. */}
|
||||
<div
|
||||
data-slot="admin-assistant-reasoning"
|
||||
className="border-border space-y-4 border-t pt-4"
|
||||
>
|
||||
<SettingsToggle
|
||||
label={t(REASONING_ADMIN_KEYS.enabled)}
|
||||
description={t(REASONING_ADMIN_KEYS.description)}
|
||||
checked={reasoningOn}
|
||||
onCheckedChange={(checked) =>
|
||||
mutation.mutate({ aiReasoningEnabled: checked })
|
||||
}
|
||||
disabled={disabledSubs}
|
||||
/>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<Label
|
||||
htmlFor="admin-reasoning-max"
|
||||
noColon
|
||||
// On the toggles' left edge: the primitive's `pl-1` would
|
||||
// inset this one label beside the switch rows above it.
|
||||
className="pl-0 text-sm font-medium"
|
||||
>
|
||||
{t(REASONING_ADMIN_KEYS.max)}
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id="admin-reasoning-max"
|
||||
data-slot="admin-reasoning-max"
|
||||
className="sm:w-48"
|
||||
value={data?.reasoning?.maxEffort ?? DEFAULT_REASONING_MAX_EFFORT}
|
||||
disabled={disabledSubs || !reasoningOn}
|
||||
onChange={(e) =>
|
||||
mutation.mutate({
|
||||
aiReasoningMaxEffort: e.target.value as ReasoningMaxEffort,
|
||||
})
|
||||
}
|
||||
>
|
||||
{REASONING_MAX_EFFORTS.map((effort) => (
|
||||
<option key={effort} value={effort}>
|
||||
{t(REASONING_LEVEL_LABEL_KEYS[effort])}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
|
||||
@@ -371,7 +371,7 @@ function PriorField({
|
||||
max={max}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="border-input bg-background focus-visible:ring-ring/50 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none"
|
||||
className="border-input bg-background focus-visible:border-input-focus w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -388,7 +388,7 @@ describe("<TodayHero>", () => {
|
||||
expect(text.match(/\b82\b/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps the full narrative composition and md score ring when a lead exists", () => {
|
||||
it("keeps the full narrative composition and the responsive score ring when a lead exists", () => {
|
||||
const html = render(
|
||||
<TodayHero
|
||||
digest={digest({
|
||||
@@ -401,7 +401,8 @@ describe("<TodayHero>", () => {
|
||||
|
||||
expect(html).toContain('data-layout="narrative"');
|
||||
expect(html).toContain('data-slot="today-hero-lead"');
|
||||
expect(html).toContain('style="width:168px;height:168px"');
|
||||
// A compact dial on a phone, the md dial (168 px) from md up.
|
||||
expect(html).toContain("size-20 md:size-42");
|
||||
expect(html).not.toContain('data-layout="compact-all-clear"');
|
||||
});
|
||||
|
||||
@@ -587,6 +588,86 @@ describe("<TodayHero> Today overview", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("puts the ring beside the lead and the overview under both on a phone", () => {
|
||||
const html = render(
|
||||
<TodayHero digest={digest({ today: FACTS, worthALook: [] })} />,
|
||||
);
|
||||
// The read grid: lead cell, overview cell, ring cell, in that order.
|
||||
const read = html.slice(html.indexOf('data-slot="today-hero-read"'));
|
||||
// The cell is the grid child wrapping the slot: the nearest preceding
|
||||
// div that carries a grid placement.
|
||||
const cellClass = (slot: string) => {
|
||||
const before = read.slice(0, read.indexOf(`data-slot="${slot}"`));
|
||||
const cells = [
|
||||
...before.matchAll(/<div class="([^"]*(?:col-|row-)[^"]*)"/g),
|
||||
];
|
||||
return cells.at(-1)?.[1] ?? "";
|
||||
};
|
||||
expect(cellClass("today-hero-lead")).toContain("row-start-1");
|
||||
// Full width under lead and ring below md, the leading column from md.
|
||||
expect(cellClass("today-hero-today")).toContain("col-span-2");
|
||||
expect(cellClass("today-hero-today")).toContain("md:col-span-1");
|
||||
// The slot's own classes, for elements that are grid items themselves.
|
||||
const slotClass = (slot: string) =>
|
||||
new RegExp(`data-slot="${slot}" class="([^"]*)"`).exec(read)?.[1] ?? "";
|
||||
// Below md the dial is the top-right cell and its meta lines a row of
|
||||
// their own under it; from md up the column spans both rows as before.
|
||||
expect(slotClass("today-hero-score")).toContain("col-start-2");
|
||||
expect(slotClass("today-hero-score")).toContain("row-start-1");
|
||||
expect(slotClass("today-hero-score-column")).toContain("contents");
|
||||
expect(slotClass("today-hero-score-column")).toContain("md:row-span-2");
|
||||
});
|
||||
|
||||
it("gives the score's meta lines a full-width row on a phone, not the dial's width", () => {
|
||||
const html = render(
|
||||
<TodayHero
|
||||
digest={digest({
|
||||
today: FACTS,
|
||||
worthALook: [],
|
||||
score: {
|
||||
value: 63,
|
||||
band: "yellow",
|
||||
delta: -6,
|
||||
deltaReason: null,
|
||||
steadyWeeks: null,
|
||||
},
|
||||
})}
|
||||
/>,
|
||||
"de",
|
||||
);
|
||||
const meta = /data-slot="today-hero-score-meta" class="([^"]*)"/.exec(
|
||||
html,
|
||||
)?.[1];
|
||||
expect(meta).toContain("col-span-2");
|
||||
expect(meta).toContain("md:contents");
|
||||
// No line is capped to the 80 px dial any more.
|
||||
expect(html).not.toContain("max-w-20");
|
||||
// The facts move one row down to make room for it.
|
||||
expect(html).toMatch(
|
||||
/class="[^"]*row-start-3[^"]*"><div[^>]*data-slot="today-hero-today"/,
|
||||
);
|
||||
});
|
||||
|
||||
it("stacks each fact's label above its value, both on the reading edge", () => {
|
||||
const html = render(<TodayHero digest={digest({ today: FACTS })} />);
|
||||
const links = [
|
||||
...html.matchAll(
|
||||
/data-slot="today-hero-fact"[^>]*><a[^>]*class="([^"]*)"[^>]*>(.*?)<\/a>/g,
|
||||
),
|
||||
];
|
||||
expect(links).toHaveLength(FACTS.length);
|
||||
for (const [, linkClass, inner] of links) {
|
||||
expect(linkClass).toContain("flex-col");
|
||||
expect(linkClass).not.toContain("justify-between");
|
||||
expect(linkClass).toContain("min-h-11");
|
||||
expect(inner).not.toContain("text-right");
|
||||
// Label muted, value foreground.
|
||||
expect(inner).toMatch(
|
||||
/text-muted-foreground[^"]*"[^>]*>[^<]+<\/span><span class="text-foreground/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("hides the fifth fact below md and never the first four", () => {
|
||||
const html = render(<TodayHero digest={digest({ today: FACTS })} />);
|
||||
const items = [
|
||||
|
||||
+196
-110
@@ -268,14 +268,19 @@ export function TodayHero({
|
||||
i >= TODAY_FACTS_NARROW && "hidden md:block",
|
||||
)}
|
||||
>
|
||||
{/* Label above value at every width, both on the reading edge.
|
||||
A label-left / value-right row pushed a long value ("5 h 45
|
||||
min, 42 min less than usual") into a ragged right-aligned
|
||||
block on a phone; stacked, every value reads as one left-
|
||||
aligned sentence. The row keeps the 44 px tap floor below md. */}
|
||||
<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"
|
||||
className="focus-visible:ring-ring/50 hover:bg-muted/50 -mx-1.5 flex min-h-11 flex-col justify-center gap-0.5 rounded-md px-1.5 py-1 focus-visible:ring-2 focus-visible:outline-none md:min-h-0 md:justify-start"
|
||||
>
|
||||
<span className="text-muted-foreground shrink-0 text-sm md:text-xs">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{fact.label}
|
||||
</span>
|
||||
<span className="text-foreground min-w-0 text-right text-sm font-medium md:text-left">
|
||||
<span className="text-foreground min-w-0 text-sm font-medium">
|
||||
{fact.value}
|
||||
</span>
|
||||
</Link>
|
||||
@@ -285,6 +290,110 @@ export function TodayHero({
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
/* The lead — the read leads large in the foreground token, calm and
|
||||
legible — and, one muted step down, the top signal's present-tense
|
||||
headline + delta, supporting it without competing. */
|
||||
const leadBlock =
|
||||
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}
|
||||
{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;
|
||||
|
||||
/* Health score ring — `flat` (no sweep/bloom), server-computed band. A
|
||||
null score paints the ring's honest provisional face at the same
|
||||
footprint, so the column never collapses. The meta lines under it are
|
||||
capped to the dial's width on a phone so the column stays compact. */
|
||||
const hasScoreMeta =
|
||||
(deltaShown && digest.score?.delta != null) || !!steadyLine || !!basisLine;
|
||||
|
||||
const scoreRing = (size: "sm" | "responsive", className?: string) => (
|
||||
<div data-slot="today-hero-score" className={className}>
|
||||
{/* The ring opens the Insights overview — the destination the
|
||||
health-score card owns — so every hero ring is a door, not a
|
||||
poster. */}
|
||||
<Link
|
||||
href="/insights"
|
||||
aria-label={t("daily.today.ringLink", {
|
||||
metric: t("daily.today.scoreLabel"),
|
||||
})}
|
||||
className="focus-visible:ring-ring/50 block rounded-full focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
<ScoreRing
|
||||
score={digest.score?.value ?? null}
|
||||
band={digest.score ? (digest.score.band as ScoreBand) : undefined}
|
||||
size={size}
|
||||
flat
|
||||
label={t("daily.today.scoreLabel")}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
|
||||
/* The score's meta lines: its delta, how long it has held, and what it
|
||||
rests on. On the phone's full-width row the basis sentence keeps one
|
||||
line; beside a dial it wraps at the dial column's width. */
|
||||
const scoreMetaLines = (size: "sm" | "responsive") => (
|
||||
<>
|
||||
{deltaShown && digest.score?.delta != null ? (
|
||||
<span
|
||||
data-slot="today-hero-score-delta"
|
||||
className="text-muted-foreground text-xs tabular-nums"
|
||||
>
|
||||
{formatDelta(digest.score.delta, t)}
|
||||
</span>
|
||||
) : null}
|
||||
{steadyLine ? (
|
||||
<span
|
||||
data-slot="today-hero-score-steady"
|
||||
className="text-muted-foreground text-xs text-balance"
|
||||
>
|
||||
{steadyLine}
|
||||
</span>
|
||||
) : null}
|
||||
{basisLine ? (
|
||||
<span
|
||||
data-slot="today-hero-score-basis"
|
||||
className={cn(
|
||||
"text-muted-foreground text-xs text-balance md:max-w-[11rem] md:text-right",
|
||||
size === "sm" && "max-w-[11rem]",
|
||||
)}
|
||||
>
|
||||
{basisLine}
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
/* Health score ring — `flat` (no sweep/bloom), server-computed band. A
|
||||
null score paints the ring's honest provisional face at the same
|
||||
footprint, so the column never collapses. The compact all-clear keeps
|
||||
the ring and its meta lines in one centred column. */
|
||||
const compactScoreColumn = (
|
||||
<div className="flex shrink-0 flex-col items-center gap-1 text-center md:items-end">
|
||||
{scoreRing("sm")}
|
||||
{scoreMetaLines("sm")}
|
||||
</div>
|
||||
);
|
||||
|
||||
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
|
||||
@@ -336,121 +445,98 @@ export function TodayHero({
|
||||
gaps let the worth-a-look rail sit close under the lead so the card
|
||||
reads filled, not padded out. */}
|
||||
<div className="flex flex-col gap-3 md:gap-4">
|
||||
{/* The day's read — the numeric face on the trailing edge, the
|
||||
narrative lead leading. On md+ the lead sits flush-top with the
|
||||
score ring (items-start) rather than floating centred beside it. */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-4",
|
||||
compactAllClear
|
||||
? "flex-row items-center justify-between"
|
||||
: "flex-col md:flex-row md:items-start md:justify-between md:gap-6",
|
||||
)}
|
||||
>
|
||||
{/* 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. */}
|
||||
{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}
|
||||
{todayBlock}
|
||||
{compactAllClear ? (
|
||||
{compactAllClear ? (
|
||||
/* Score-only digest: the all-clear copy leads and a smaller
|
||||
version of the same ring stays the one numeric face. */
|
||||
<div className="flex flex-row items-center justify-between gap-4">
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
<p
|
||||
data-slot="today-hero-all-clear"
|
||||
className="text-muted-foreground text-sm"
|
||||
>
|
||||
{t("daily.today.allClear")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{/* In the compact fallback the quiet freshness tier belongs next
|
||||
to the smaller ring. That uses the otherwise-empty leading
|
||||
space and avoids adding a second row below the dial. */}
|
||||
{compactAllClear ? sleepPendingNote : null}
|
||||
</div>
|
||||
|
||||
{/* Health score ring — `flat` (no sweep/bloom), server-computed
|
||||
band. A null score paints the ring's honest provisional face
|
||||
at the same footprint, so the column never collapses. */}
|
||||
<div className="flex shrink-0 flex-col items-center gap-1 md:items-end">
|
||||
<div data-slot="today-hero-score">
|
||||
{/* The ring opens the Insights overview — the destination the
|
||||
health-score card owns — matching the cluster rings' tap
|
||||
behaviour, so every hero ring is a door, not a poster. */}
|
||||
<Link
|
||||
href="/insights"
|
||||
aria-label={t("daily.today.ringLink", {
|
||||
metric: t("daily.today.scoreLabel"),
|
||||
})}
|
||||
className="focus-visible:ring-ring/50 block rounded-full focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
<ScoreRing
|
||||
score={digest.score?.value ?? null}
|
||||
band={
|
||||
digest.score ? (digest.score.band as ScoreBand) : undefined
|
||||
}
|
||||
size={compactAllClear ? "sm" : "md"}
|
||||
flat
|
||||
label={t("daily.today.scoreLabel")}
|
||||
/>
|
||||
</Link>
|
||||
{/* The quiet freshness tier belongs next to the smaller ring:
|
||||
it uses the otherwise-empty leading space and adds no
|
||||
second row below the dial. */}
|
||||
{sleepPendingNote}
|
||||
</div>
|
||||
{deltaShown && digest.score?.delta != null ? (
|
||||
<span
|
||||
data-slot="today-hero-score-delta"
|
||||
className="text-muted-foreground text-xs tabular-nums"
|
||||
>
|
||||
{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"
|
||||
className="text-muted-foreground max-w-[11rem] text-center text-xs text-balance md:text-right"
|
||||
>
|
||||
{basisLine}
|
||||
</span>
|
||||
) : null}
|
||||
{compactScoreColumn}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* The day's read: the narrative lead on the reading edge, the
|
||||
numeric face on the trailing one, the Today overview under the
|
||||
lead.
|
||||
|
||||
One grid, two arrangements. Below md the ring is a compact dial
|
||||
at the top right beside the lead, and the overview runs the full
|
||||
card width underneath both; a phone has no room for a 168 px
|
||||
dial under the facts, where it used to sit centred and fall
|
||||
behind the bottom navigation. From md up the ring spans both
|
||||
rows of the trailing column and the overview stays in the
|
||||
leading column, flush-top with the ring, exactly as before.
|
||||
|
||||
Without a lead the overview takes the leading cell itself, so
|
||||
the ring never floats beside an empty row. */
|
||||
<div
|
||||
data-slot="today-hero-read"
|
||||
className={cn(
|
||||
"grid grid-cols-[minmax(0,1fr)_auto] gap-x-4 gap-y-4 md:gap-x-6 md:gap-y-6",
|
||||
// A ring taller than the lead pushes its extra height into the
|
||||
// second row only, so the overview stays right under the lead.
|
||||
leadBlock && todayBlock && "md:grid-rows-[auto_1fr]",
|
||||
)}
|
||||
>
|
||||
{leadBlock ? (
|
||||
<div className="col-start-1 row-start-1 min-w-0 self-start">
|
||||
{leadBlock}
|
||||
</div>
|
||||
) : null}
|
||||
{todayBlock ? (
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0",
|
||||
leadBlock
|
||||
? cn(
|
||||
"col-span-2 md:col-span-1 md:col-start-1 md:row-start-2",
|
||||
hasScoreMeta ? "row-start-3" : "row-start-2",
|
||||
)
|
||||
: "col-start-1 row-start-1",
|
||||
)}
|
||||
>
|
||||
{todayBlock}
|
||||
</div>
|
||||
) : null}
|
||||
{/* One column from md up (the ring, its meta lines under it,
|
||||
trailing edge), as before. Below md the column dissolves
|
||||
(`contents`) into two grid items: the dial in the top-right
|
||||
cell beside the lead, and the meta lines on a full-width row
|
||||
right after the read row, flush with the dial's edge: under
|
||||
the dial beside a short lead, under a long lead's last line.
|
||||
In the dial's 80 px column the German lines broke into three
|
||||
to five rows each and pushed the facts down. */}
|
||||
<div
|
||||
data-slot="today-hero-score-column"
|
||||
className={cn(
|
||||
"contents md:col-start-2 md:row-start-1 md:flex md:shrink-0 md:flex-col md:items-end md:gap-1 md:self-start md:text-right",
|
||||
leadBlock && todayBlock && "md:row-span-2",
|
||||
)}
|
||||
>
|
||||
{scoreRing(
|
||||
"responsive",
|
||||
"col-start-2 row-start-1 self-start md:self-auto",
|
||||
)}
|
||||
{hasScoreMeta ? (
|
||||
<div
|
||||
data-slot="today-hero-score-meta"
|
||||
className="col-span-2 row-start-2 -mt-3 flex flex-col items-end gap-1 text-right md:contents"
|
||||
>
|
||||
{scoreMetaLines("responsive")}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Meta row — the hero's quiet tier (UI-STANDARDS §text: `text-xs
|
||||
text-muted-foreground` is the meta floor; never an accent, never an
|
||||
|
||||
@@ -989,7 +989,7 @@ export function DocumentDetailSheet({
|
||||
setTitleDraft(doc.title ?? "");
|
||||
setEditingTitle(true);
|
||||
}}
|
||||
className="border-input hover:bg-muted/50 focus-visible:ring-ring/50 flex min-h-10 w-full items-center justify-between gap-2 rounded-md border px-3 py-2 text-left text-sm focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
className="border-input hover:bg-muted/50 focus-visible:border-input-focus flex min-h-10 w-full items-center justify-between gap-2 rounded-md border px-3 py-2 text-left text-sm focus-visible:outline-none"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* v1.41 — "Assumed: last 30 days. Change": one quiet line, the change only
|
||||
* under the latest answer and only with alternatives.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { CoachAssumption, CoachFollowUp } from "@/lib/ai/coach/types";
|
||||
|
||||
import { CoachAssumptionLine, alternativesFor } from "../assumption-line";
|
||||
|
||||
const WINDOW: CoachAssumption = {
|
||||
kind: "window",
|
||||
value: {
|
||||
labelKey: "coach.step.window.last30days",
|
||||
label: "last 30 days",
|
||||
value: { window: "last30days" },
|
||||
},
|
||||
alternatives: [],
|
||||
};
|
||||
const CHANGE: CoachFollowUp = {
|
||||
id: "f1",
|
||||
kind: "change_assumption",
|
||||
labelKey: "coach.followUp.changeAssumption",
|
||||
label: "Last 90 days instead",
|
||||
reuse: false,
|
||||
origin: "server",
|
||||
assumption: { kind: "window", value: { window: "last90days" } },
|
||||
};
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<CoachAssumptionLine>", () => {
|
||||
it("names the assumption in one meta line", () => {
|
||||
const html = render(
|
||||
<CoachAssumptionLine
|
||||
assumptions={[WINDOW]}
|
||||
changes={[]}
|
||||
messageId={null}
|
||||
disabled={false}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Assumed: last 30 days.");
|
||||
expect(html).toMatch(
|
||||
/data-slot="coach-assumption-line"[^>]*class="text-muted-foreground/,
|
||||
);
|
||||
expect(html).not.toContain("coach-assumption-change");
|
||||
});
|
||||
|
||||
it("offers Change under the latest answer, closed until tapped", () => {
|
||||
const html = render(
|
||||
<CoachAssumptionLine
|
||||
assumptions={[WINDOW]}
|
||||
changes={[CHANGE]}
|
||||
messageId="m1"
|
||||
disabled={false}
|
||||
onChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain(">Change</button>");
|
||||
expect(html).toContain('aria-expanded="false"');
|
||||
expect(html).not.toContain("Last 90 days instead");
|
||||
});
|
||||
|
||||
it("matches alternatives to their assumption by kind", () => {
|
||||
expect(alternativesFor(WINDOW, [CHANGE])).toEqual([CHANGE]);
|
||||
expect(alternativesFor({ ...WINDOW, kind: "metric" }, [CHANGE])).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,112 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { isValidElement, type ReactElement, type ReactNode } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
|
||||
// The card's only hook is the translator; the server one resolves the same
|
||||
// bundles and returns the key for a missing string, like the client one. With
|
||||
// it, the card can be called as a plain function and its handlers exercised
|
||||
// (the node test environment has no DOM to click through).
|
||||
vi.mock("@/lib/i18n/context", () => ({
|
||||
useTranslations: () => ({
|
||||
t: (key: string) => getServerTranslator("en").t(key),
|
||||
}),
|
||||
}));
|
||||
|
||||
const focusComposer = vi.fn();
|
||||
vi.mock("../composer-focus", () => ({
|
||||
focusCoachComposer: () => focusComposer(),
|
||||
}));
|
||||
|
||||
import { CoachClarificationCard } from "../clarification-card";
|
||||
import type { CoachClarification } from "@/lib/ai/coach/types";
|
||||
|
||||
const METRIC: CoachClarification = {
|
||||
kind: "metric",
|
||||
choices: [
|
||||
{
|
||||
id: "c1",
|
||||
labelKey: "insights.coach.metric.pulse",
|
||||
label: "Pulse (server)",
|
||||
value: { metric: "pulse" },
|
||||
},
|
||||
{
|
||||
id: "c2",
|
||||
labelKey: "coach.not.a.key",
|
||||
label: "Server label",
|
||||
value: { metric: "resting_hr" },
|
||||
},
|
||||
],
|
||||
freeText: true,
|
||||
};
|
||||
|
||||
function card(clarification: CoachClarification, disabled = false) {
|
||||
const onChoose = vi.fn();
|
||||
const tree = CoachClarificationCard({
|
||||
clarification,
|
||||
messageId: "m1",
|
||||
disabled,
|
||||
onChoose,
|
||||
});
|
||||
return { tree, onChoose, html: renderToStaticMarkup(tree) };
|
||||
}
|
||||
|
||||
function choiceButtons(
|
||||
node: ReactNode,
|
||||
): ReactElement<Record<string, unknown>>[] {
|
||||
if (Array.isArray(node)) return node.flatMap(choiceButtons);
|
||||
if (!isValidElement(node)) return [];
|
||||
const el = node as ReactElement<Record<string, unknown>>;
|
||||
const own = el.props["data-choice-id"] ? [el] : [];
|
||||
return [...own, ...choiceButtons(el.props.children as ReactNode)];
|
||||
}
|
||||
|
||||
describe("<CoachClarificationCard>", () => {
|
||||
it("is labelled, takes no focus, and leaves announcing to the live region beside it", () => {
|
||||
const { html } = card(METRIC);
|
||||
// The card mounts with its content, so a live region on the card itself
|
||||
// is never heard; the conversation keeps one mounted for it.
|
||||
expect(html).not.toContain("aria-live");
|
||||
expect(html).toContain('aria-label="The Coach has a question"');
|
||||
expect(html).not.toContain("autofocus");
|
||||
expect(html).not.toContain("autoFocus");
|
||||
});
|
||||
|
||||
it("renders the catalog label, falling back to the server label", () => {
|
||||
const { html } = card(METRIC);
|
||||
expect(html).toContain('aria-label="Possible answers"');
|
||||
expect(html).toContain(">Pulse</button>");
|
||||
expect(html).toContain(">Server label</button>");
|
||||
expect(html).toContain("You can also type your own answer.");
|
||||
});
|
||||
|
||||
it("sends the tapped choice", () => {
|
||||
const { tree, onChoose } = card(METRIC);
|
||||
const buttons = choiceButtons(tree);
|
||||
expect(buttons).toHaveLength(2);
|
||||
focusComposer.mockClear();
|
||||
(buttons[1].props.onClick as () => void)();
|
||||
expect(onChoose).toHaveBeenCalledWith(METRIC.choices[1]);
|
||||
// The choices disable while the answer runs: focus goes to the composer.
|
||||
expect(focusComposer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("disables the choices while a turn is in flight", () => {
|
||||
const { tree } = card(METRIC, true);
|
||||
for (const button of choiceButtons(tree)) {
|
||||
expect(button.props.disabled).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("asks for a typed answer on a context question", () => {
|
||||
const { html, tree } = card({
|
||||
kind: "context",
|
||||
choices: [],
|
||||
freeText: true,
|
||||
});
|
||||
expect(choiceButtons(tree)).toHaveLength(0);
|
||||
expect(html).not.toContain('role="group"');
|
||||
expect(html).toContain("Type your answer below.");
|
||||
});
|
||||
});
|
||||
@@ -113,16 +113,15 @@ describe("<CoachConversation> page deep-link (#67)", () => {
|
||||
expect(html).not.toContain('data-slot="coach-hero"');
|
||||
});
|
||||
|
||||
it("keeps the clarification live region mounted before any question arrives", () => {
|
||||
// A region that mounts together with its content is not announced; the
|
||||
// empty region has to be in the page first.
|
||||
it("asks no question in a card above the composer", () => {
|
||||
// v1.41 — a clarifying question is a normal Coach message with reply
|
||||
// pills under it; there is no card and no separate live region.
|
||||
const html = render(
|
||||
<CoachConversation surface="page" initialConversationId="older" />,
|
||||
makeClient(),
|
||||
);
|
||||
expect(html).toMatch(
|
||||
/<div[^>]*data-slot="coach-clarification-live"[^>]*aria-live="polite"[^>]*class="sr-only"[^>]*><\/div>/,
|
||||
);
|
||||
expect(html).not.toContain("coach-clarification-card");
|
||||
expect(html).not.toContain("coach-clarification-live");
|
||||
});
|
||||
|
||||
it("auto-opens the most-recent thread when none is pinned", () => {
|
||||
|
||||
@@ -8,7 +8,6 @@ import type {
|
||||
import {
|
||||
chainWithFirst,
|
||||
modelControlFor,
|
||||
reasoningFieldFor,
|
||||
selectableProviders,
|
||||
switchDidNotTake,
|
||||
} from "../coach-model-picker";
|
||||
@@ -120,20 +119,3 @@ describe("modelControlFor", () => {
|
||||
).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,166 @@
|
||||
/**
|
||||
* v1.41 — the Coach's thinking depth: Off / Low / Medium / High, rendered
|
||||
* from what the server resolved, never recomputed.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
|
||||
const locale = vi.hoisted(() => ({ current: "en" as "en" | "de" }));
|
||||
vi.mock("@/lib/i18n/context", () => ({
|
||||
useTranslations: () => ({
|
||||
t: (key: string, params?: Record<string, string | number>) =>
|
||||
getServerTranslator(locale.current).t(key, params),
|
||||
}),
|
||||
}));
|
||||
|
||||
import {
|
||||
CoachReasoningSelect,
|
||||
reasoningLocked,
|
||||
reasoningOptions,
|
||||
reasoningStateOrDefault,
|
||||
shownReasoningLevel,
|
||||
type CoachReasoningState,
|
||||
} from "../coach-reasoning-field";
|
||||
|
||||
const OPEN: CoachReasoningState = {
|
||||
level: "medium",
|
||||
preference: "medium",
|
||||
maxLevel: "high",
|
||||
available: true,
|
||||
offIsReal: true,
|
||||
source: "user",
|
||||
};
|
||||
const ADMIN_OFF: CoachReasoningState = {
|
||||
level: "off",
|
||||
preference: "high",
|
||||
maxLevel: "off",
|
||||
available: false,
|
||||
offIsReal: true,
|
||||
source: "admin_off",
|
||||
};
|
||||
|
||||
function render(state: CoachReasoningState, value = state.level) {
|
||||
return renderToStaticMarkup(
|
||||
<CoachReasoningSelect
|
||||
id="r"
|
||||
value={value}
|
||||
state={state}
|
||||
onChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("the resolved block", () => {
|
||||
it("reads an older server's missing block as allowed, uncapped, really off", () => {
|
||||
expect(reasoningStateOrDefault(null)).toEqual(OPEN);
|
||||
expect(reasoningStateOrDefault(undefined)).toEqual(OPEN);
|
||||
});
|
||||
|
||||
it("is locked only when the operator switched reasoning off", () => {
|
||||
expect(reasoningLocked(ADMIN_OFF)).toBe(true);
|
||||
expect(reasoningLocked(OPEN)).toBe(false);
|
||||
// A provider that cannot reason does not lock the person's choice.
|
||||
expect(
|
||||
reasoningLocked({ ...OPEN, available: false, source: "unsupported" }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("<CoachReasoningSelect>", () => {
|
||||
it("offers Off, Low, Medium and High with the label and hint", () => {
|
||||
const html = render(OPEN);
|
||||
expect(html).toContain(">Thinking depth<");
|
||||
for (const label of ["Off", "Low", "Medium", "High"]) {
|
||||
expect(html).toContain(`>${label}</option>`);
|
||||
}
|
||||
expect(html).toContain(
|
||||
"Deeper thinking means more precise answers and a little more waiting.",
|
||||
);
|
||||
expect(html).not.toMatch(/<select[^>]*\sdisabled=""/);
|
||||
});
|
||||
|
||||
it("is locked with one sentence when the admin turned reasoning off", () => {
|
||||
const html = render(ADMIN_OFF, "high");
|
||||
expect(html).toMatch(/<select[^>]*\sdisabled=""/);
|
||||
expect(html).toContain('data-locked="true"');
|
||||
expect(html).toContain("The admin has turned deeper thinking off.");
|
||||
expect(html).not.toContain("a little more waiting");
|
||||
});
|
||||
|
||||
it("shows the admin's cap: levels above it are disabled and say why", () => {
|
||||
const html = render({ ...OPEN, maxLevel: "medium" });
|
||||
expect(html).toMatch(
|
||||
/<option value="high" disabled="">High \(limited by the admin\)<\/option>/,
|
||||
);
|
||||
expect(html).toMatch(
|
||||
/<option value="medium"( selected="")?>Medium<\/option>/,
|
||||
);
|
||||
});
|
||||
|
||||
it("says Minimal where the provider cannot switch thinking off", () => {
|
||||
const options = reasoningOptions(
|
||||
{ ...OPEN, offIsReal: false },
|
||||
getServerTranslator("en").t,
|
||||
);
|
||||
expect(options[0]).toEqual({
|
||||
value: "off",
|
||||
label: "Minimal",
|
||||
disabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("shows a choice above the cap at the cap, which is what runs", () => {
|
||||
expect(shownReasoningLevel("high", { ...OPEN, maxLevel: "low" })).toBe(
|
||||
"low",
|
||||
);
|
||||
expect(shownReasoningLevel("low", OPEN)).toBe("low");
|
||||
expect(shownReasoningLevel("high", ADMIN_OFF)).toBe("off");
|
||||
});
|
||||
|
||||
it("reads in German", () => {
|
||||
locale.current = "de";
|
||||
const html = render({ ...OPEN, maxLevel: "low" });
|
||||
locale.current = "en";
|
||||
expect(html).toContain(">Denktiefe<");
|
||||
expect(html).toContain("Hoch (vom Admin begrenzt)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the two homes of the field", () => {
|
||||
const selectClass = (html: string) =>
|
||||
html.match(/<select[^>]*class="([^"]*)"/)?.[1] ?? "";
|
||||
|
||||
it("stacks as wide as the quick settings sheet", () => {
|
||||
const html = render(OPEN);
|
||||
expect(html).toContain('data-no-colon="true"');
|
||||
expect(selectClass(html)).toMatch(/\bw-full\b/);
|
||||
expect(selectClass(html)).toMatch(/\bmt-1\b/);
|
||||
});
|
||||
|
||||
it("takes the shape of Tone and Reply length in Settings → Coach", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CoachReasoningSelect
|
||||
id="r"
|
||||
variant="card"
|
||||
value="medium"
|
||||
state={OPEN}
|
||||
onChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
// Their small label with its colon, a content-wide select, the card's
|
||||
// own gap instead of margins.
|
||||
expect(html).toMatch(/data-slot="coach-reasoning-field" class="[^"]*gap-2/);
|
||||
expect(html).toContain('data-no-colon="false"');
|
||||
expect(html).toMatch(/<label[^>]*class="[^"]*\btext-xs\b/);
|
||||
expect(selectClass(html)).toMatch(/\bw-fit\b/);
|
||||
expect(selectClass(html)).not.toMatch(/\bw-full\b|\bmt-1\b/);
|
||||
});
|
||||
|
||||
it("looks locked when the operator switched reasoning off", () => {
|
||||
const html = render(ADMIN_OFF);
|
||||
expect(html).toMatch(/<select[^>]*\sdisabled=""/);
|
||||
expect(selectClass(html)).toContain("disabled:opacity-50");
|
||||
});
|
||||
});
|
||||
@@ -100,4 +100,39 @@ describe("resolveCoachSendTarget", () => {
|
||||
expect(target.url).toBe("/api/insights/chat");
|
||||
},
|
||||
);
|
||||
|
||||
it("carries a memory or plan decision on the tool route, never on the fenced one", () => {
|
||||
const memory = JSON.parse(
|
||||
resolveCoachSendTarget({
|
||||
conversationId: "c1",
|
||||
message: "Yes, remember it",
|
||||
memoryDecision: { messageId: "a1", proposalId: "p1", accept: true },
|
||||
}).body,
|
||||
);
|
||||
expect(memory.memoryDecision).toEqual({
|
||||
messageId: "a1",
|
||||
proposalId: "p1",
|
||||
accept: true,
|
||||
});
|
||||
const plan = JSON.parse(
|
||||
resolveCoachSendTarget({
|
||||
conversationId: "c1",
|
||||
message: "Not now",
|
||||
planDecision: { messageId: "a1", planId: "plan1", accept: false },
|
||||
}).body,
|
||||
);
|
||||
expect(plan.planDecision).toEqual({
|
||||
messageId: "a1",
|
||||
planId: "plan1",
|
||||
accept: false,
|
||||
});
|
||||
const fenced = resolveCoachSendTarget({
|
||||
conversationId: "c1",
|
||||
message: "Yes",
|
||||
fenced: true,
|
||||
memoryDecision: { messageId: "a1", proposalId: "p1", accept: true },
|
||||
});
|
||||
expect(fenced.url).toBe("/api/insights/chat/fenced");
|
||||
expect(JSON.parse(fenced.body).memoryDecision).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -109,13 +109,13 @@ describe("<CoachSettingsBody>", () => {
|
||||
|
||||
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.
|
||||
// the provider and model selects already lock, the thinking depth too.
|
||||
const source = (await import("node:fs")).readFileSync(
|
||||
new URL("../coach-model-picker.tsx", import.meta.url),
|
||||
new URL("../coach-reasoning-field.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const field = source.match(/<ReasoningEffortField[\s\S]*?\/>/)?.[0];
|
||||
expect(field).toMatch(/disabled=\{busy\}/);
|
||||
expect(source).toMatch(/disabled=\{locked \|\| busy\}/);
|
||||
expect(source).toMatch(/busy=\{save\.isPending\}/);
|
||||
});
|
||||
|
||||
it("names the data section so a deep link can land on it", () => {
|
||||
|
||||
@@ -141,15 +141,15 @@ describe("<HistoryRail>", () => {
|
||||
expect(html).toContain("Your conversations will appear here.");
|
||||
});
|
||||
|
||||
it("mounts the search input with the localised placeholder", () => {
|
||||
it("carries no search field (search lives on the full conversations page)", () => {
|
||||
const client = makeClientWithConversations(samplePage);
|
||||
const html = render(
|
||||
<HistoryRail activeId={null} onSelect={() => {}} />,
|
||||
client,
|
||||
);
|
||||
expect(html).toMatch(
|
||||
/data-slot="coach-history-search"[^>]*placeholder="Search conversations…"/,
|
||||
);
|
||||
expect(html).toContain('data-slot="coach-history-list"');
|
||||
expect(html).not.toContain('type="search"');
|
||||
expect(html).not.toContain("Search conversations");
|
||||
});
|
||||
|
||||
it("uses German strings when locale is 'de'", () => {
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* v1.41 — the one quiet line under an answer that says the Coach kept
|
||||
* something, with Undo.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { isValidElement, type ReactElement, type ReactNode } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
|
||||
const locale = vi.hoisted(() => ({ current: "en" as "en" | "de" }));
|
||||
vi.mock("@/lib/i18n/context", () => ({
|
||||
useTranslations: () => ({
|
||||
t: (key: string, params?: Record<string, string | number>) =>
|
||||
getServerTranslator(locale.current).t(key, params),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { CoachMemoryNote, CoachMemoryNoteLine } from "../memory-note";
|
||||
|
||||
function render(node: ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{node}
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function find(
|
||||
node: ReactNode,
|
||||
slot: string,
|
||||
): ReactElement<Record<string, unknown>> | null {
|
||||
if (Array.isArray(node)) {
|
||||
for (const child of node) {
|
||||
const hit = find(child, slot);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (!isValidElement(node)) return null;
|
||||
const el = node as ReactElement<Record<string, unknown>>;
|
||||
if (el.props["data-slot"] === slot) return el;
|
||||
return find(el.props.children as ReactNode, slot);
|
||||
}
|
||||
|
||||
describe("<CoachMemoryNoteLine>", () => {
|
||||
it("says what was kept as one meta line, with Undo as a text button", () => {
|
||||
locale.current = "en";
|
||||
const html = render(
|
||||
<CoachMemoryNoteLine
|
||||
fact="You want to reach 75 kg by December."
|
||||
pending={false}
|
||||
onUndo={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="coach-memory-note"');
|
||||
expect(html).toContain("Remembered: You want to reach 75 kg by December.");
|
||||
expect(html).toMatch(/class="text-muted-foreground[^"]*text-xs/);
|
||||
expect(html).toContain(">Undo</button>");
|
||||
expect(html).not.toMatch(/\bbg-primary\b|text-muted-foreground\/\d/);
|
||||
});
|
||||
|
||||
it("reads in German", () => {
|
||||
locale.current = "de";
|
||||
const html = render(
|
||||
<CoachMemoryNoteLine
|
||||
fact="Ziel 75 kg"
|
||||
pending={false}
|
||||
onUndo={() => {}}
|
||||
/>,
|
||||
);
|
||||
locale.current = "en";
|
||||
expect(html).toContain("Gemerkt: Ziel 75 kg.");
|
||||
expect(html).toContain(">Rückgängig</button>");
|
||||
});
|
||||
|
||||
it("Undo forgets the fact: one tap, one call", () => {
|
||||
const onUndo = vi.fn();
|
||||
const tree = CoachMemoryNoteLine({ fact: "x", pending: false, onUndo });
|
||||
const undo = find(tree, "coach-memory-note-undo");
|
||||
expect(undo).not.toBeNull();
|
||||
(undo?.props.onClick as () => void)();
|
||||
expect(onUndo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("locks Undo while the forget is on its way", () => {
|
||||
const tree = CoachMemoryNoteLine({
|
||||
fact: "x",
|
||||
pending: true,
|
||||
onUndo: () => {},
|
||||
});
|
||||
expect(find(tree, "coach-memory-note-undo")?.props.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("<CoachMemoryNote>", () => {
|
||||
it("shows a saved fact from the live frame", () => {
|
||||
const html = render(
|
||||
<CoachMemoryNote
|
||||
note={{ proposal: false, factId: "f1", category: "goal" }}
|
||||
liveFact="Reach 75 kg by December"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Remembered: Reach 75 kg by December.");
|
||||
});
|
||||
|
||||
it("shows nothing for a proposal: the reply pills carry it", () => {
|
||||
const html = render(
|
||||
<CoachMemoryNote
|
||||
note={{ proposal: true, proposalId: "p1", category: "medication" }}
|
||||
liveFact="Takes ramipril"
|
||||
/>,
|
||||
);
|
||||
expect(html).toBe("");
|
||||
});
|
||||
|
||||
it("shows nothing for a persisted note whose fact is not in the list", () => {
|
||||
const html = render(
|
||||
<CoachMemoryNote
|
||||
note={{ proposal: false, factId: "gone", category: "goal" }}
|
||||
/>,
|
||||
);
|
||||
expect(html).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -192,15 +192,20 @@ describe("user action row", () => {
|
||||
describe("follow-up chips in the answer column", () => {
|
||||
const offer = { messageId: "m2", followUps: CHIPS };
|
||||
|
||||
it("render inside the column, after the action row", () => {
|
||||
it("render inside the column, right after the answer, above the action row", () => {
|
||||
const html = render(
|
||||
<ChatBubble {...ANSWER} followUps={offer} onFollowUp={() => {}} />,
|
||||
);
|
||||
const column = slot(html, "coach-answer-column");
|
||||
const answer = column.indexOf('data-slot="coach-answer-bubble"');
|
||||
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);
|
||||
expect(answer).toBeGreaterThan(-1);
|
||||
expect(chips).toBeGreaterThan(answer);
|
||||
// The row is the last thing in the column: beside a pointer it is
|
||||
// invisible until hover, and above the chips it left an empty band
|
||||
// between the answer and its replies.
|
||||
expect(actions).toBeGreaterThan(chips);
|
||||
// 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/,
|
||||
|
||||
@@ -100,6 +100,10 @@ function streaming(
|
||||
results: [],
|
||||
followUps: [],
|
||||
clarification: null,
|
||||
activity: [],
|
||||
memoryNote: null,
|
||||
planProposal: null,
|
||||
stop: null,
|
||||
inProgress: false,
|
||||
messageId: null,
|
||||
errorCode: null,
|
||||
@@ -248,10 +252,10 @@ describe("<MessageThread>", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("folds an older message's provenance into the areas header, never a raw key", () => {
|
||||
it("folds an older message's provenance into the thought-process row, never a raw key", () => {
|
||||
const html = render(<MessageThread conversation={baseConversation} />);
|
||||
expect(html).toContain('data-slot="coach-turn-steps-done"');
|
||||
expect(html).toContain("Looked at 1 area");
|
||||
expect(html).toContain("Thought process · 1 step");
|
||||
expect(html).not.toContain('data-slot="coach-source-chips"');
|
||||
expect(html).not.toContain("insights.coach.metric.");
|
||||
expect(html).not.toContain("n=26");
|
||||
@@ -474,16 +478,16 @@ describe("<MessageThread>", () => {
|
||||
expect(html).not.toContain('data-slot="coach-evidence"');
|
||||
expect(html).not.toContain("What I'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");
|
||||
// The areas the answer drew on stay reachable from the row.
|
||||
expect(html).toContain("Thought process · 1 step");
|
||||
});
|
||||
|
||||
it("names the areas header in German under 'de'", () => {
|
||||
it("names the thought-process row in German under 'de'", () => {
|
||||
const html = render(
|
||||
<MessageThread conversation={baseConversation} />,
|
||||
"de",
|
||||
);
|
||||
expect(html).toContain("1 Bereich angesehen");
|
||||
expect(html).toContain("Denkprozess · 1 Schritt");
|
||||
expect(html).not.toContain("Worauf bezieht sich das?");
|
||||
});
|
||||
|
||||
@@ -790,6 +794,12 @@ describe("errorCodeToI18nKey", () => {
|
||||
|
||||
// v1.4.43 QoL (M6) — `coach.network` no longer collapses to the
|
||||
// generic provider copy; the user needs the actionable offline hint.
|
||||
it("maps the demo's refusal to its own honest line, not the outage copy", () => {
|
||||
expect(errorCodeToI18nKey("demo.readOnly")).toBe(
|
||||
"insights.coach.errorDemo",
|
||||
);
|
||||
});
|
||||
|
||||
it("maps coach.network to the dedicated offline key", () => {
|
||||
expect(errorCodeToI18nKey("coach.network")).toBe(
|
||||
"insights.coach.errorNetwork",
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
import { describe, it, expect } 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 type { CoachPlanDTO } from "@/hooks/use-coach-plans";
|
||||
import {
|
||||
PlanProposalCards,
|
||||
PROPOSAL_POLL_MS,
|
||||
PROPOSAL_POLL_MAX_ATTEMPTS,
|
||||
nextProposalPollInterval,
|
||||
} from "../plan-proposal-card";
|
||||
|
||||
/**
|
||||
* The plan-proposal confirm cards at the thread tail. SSR string render
|
||||
* against a pre-seeded query cache (the `status:proposed` slot the component
|
||||
* reads), so the tests pin the render contract without a network layer:
|
||||
* only proposals born in the OPEN conversation render, the prose reads in
|
||||
* the card, and both actions are present as real buttons.
|
||||
*/
|
||||
|
||||
function plan(overrides: Partial<CoachPlanDTO> = {}): CoachPlanDTO {
|
||||
return {
|
||||
id: "p1",
|
||||
metric: "WEIGHT",
|
||||
ifCue: "every morning",
|
||||
thenAction: "weigh in before breakfast",
|
||||
target: "steady trend by autumn",
|
||||
status: "proposed",
|
||||
reviewDate: null,
|
||||
sourceConversationId: "conv-1",
|
||||
createdAt: "2026-07-01T00:00:00.000Z",
|
||||
updatedAt: "2026-07-01T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function render(conversationId: string, plans: CoachPlanDTO[]) {
|
||||
const client = new QueryClient();
|
||||
client.setQueryData(queryKeys.coachPlans("status:proposed"), plans);
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={client}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<PlanProposalCards conversationId={conversationId} />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<PlanProposalCards>", () => {
|
||||
it("renders nothing when the conversation has no open proposal", () => {
|
||||
expect(render("conv-1", [])).toBe("");
|
||||
});
|
||||
|
||||
it("renders a confirm card for a proposal born in the open conversation", () => {
|
||||
const html = render("conv-1", [plan()]);
|
||||
expect(html).toContain('data-slot="coach-plan-proposal-card"');
|
||||
// The plan prose is content — visible in the card.
|
||||
expect(html).toContain("every morning");
|
||||
expect(html).toContain("weigh in before breakfast");
|
||||
expect(html).toContain("steady trend by autumn");
|
||||
expect(html).toContain("WEIGHT");
|
||||
// Both actions render as real buttons.
|
||||
expect(html).toContain('data-slot="coach-plan-proposal-accept"');
|
||||
expect(html).toContain('data-slot="coach-plan-proposal-decline"');
|
||||
});
|
||||
|
||||
it("hides proposals born in a different conversation", () => {
|
||||
const html = render("conv-2", [plan()]);
|
||||
expect(html).toBe("");
|
||||
});
|
||||
|
||||
it("skips a proposal whose prose is undecryptable (null fields)", () => {
|
||||
const html = render("conv-1", [plan({ ifCue: null, thenAction: null })]);
|
||||
expect(html).toBe("");
|
||||
});
|
||||
|
||||
it("renders one card per open proposal", () => {
|
||||
const html = render("conv-1", [
|
||||
plan(),
|
||||
plan({ id: "p2", ifCue: "after dinner", thenAction: "short walk" }),
|
||||
]);
|
||||
expect(html.match(/coach-plan-proposal-card/g)?.length).toBe(2);
|
||||
expect(html).toContain("after dinner");
|
||||
});
|
||||
});
|
||||
|
||||
describe("nextProposalPollInterval", () => {
|
||||
it("polls at the slow interval below the attempt ceiling", () => {
|
||||
expect(nextProposalPollInterval(0)).toBe(PROPOSAL_POLL_MS);
|
||||
expect(nextProposalPollInterval(PROPOSAL_POLL_MAX_ATTEMPTS - 1)).toBe(
|
||||
PROPOSAL_POLL_MS,
|
||||
);
|
||||
});
|
||||
|
||||
it("stops polling once the attempt ceiling is reached", () => {
|
||||
// Watched red: asserting an interval at the cap fails — the ceiling is
|
||||
// live, not decorative.
|
||||
expect(nextProposalPollInterval(PROPOSAL_POLL_MAX_ATTEMPTS)).toBe(false);
|
||||
expect(nextProposalPollInterval(PROPOSAL_POLL_MAX_ATTEMPTS + 5)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* v1.41 — what the latest answer waits for (a clarifying question, a fact
|
||||
* proposal, a plan proposal) becomes reply pills, and a tap answers it.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import type { CoachMessageDTO, CoachProvenance } from "@/lib/ai/coach/types";
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({ useAuth: () => ({ user: null }) }));
|
||||
|
||||
import {
|
||||
decisionReplies,
|
||||
latestDecision,
|
||||
type CoachReplyIntent,
|
||||
} from "../message-thread";
|
||||
import type { CoachStreamingMessage } from "../use-coach";
|
||||
|
||||
const { t } = getServerTranslator("en");
|
||||
|
||||
function assistant(id: string, metricSource: Partial<CoachProvenance>) {
|
||||
return {
|
||||
id,
|
||||
role: "assistant",
|
||||
content: "…",
|
||||
createdAt: "2026-10-06T10:00:00.000Z",
|
||||
metricSource: { windows: [], metrics: [], ...metricSource },
|
||||
providerType: "mock",
|
||||
promptVersion: null,
|
||||
tokensUsed: null,
|
||||
model: null,
|
||||
} as unknown as CoachMessageDTO;
|
||||
}
|
||||
const user = (id: string) =>
|
||||
({
|
||||
id,
|
||||
role: "user",
|
||||
content: "Yes",
|
||||
createdAt: "2026-10-06T10:01:00.000Z",
|
||||
metricSource: null,
|
||||
}) as unknown as CoachMessageDTO;
|
||||
|
||||
const CLARIFY = {
|
||||
kind: "metric" as const,
|
||||
freeText: true,
|
||||
assumption: "c2",
|
||||
choices: [
|
||||
{
|
||||
id: "c1",
|
||||
labelKey: "insights.coach.metric.pulse",
|
||||
label: "Pulse",
|
||||
value: { metric: "pulse" as const },
|
||||
},
|
||||
{
|
||||
id: "c2",
|
||||
labelKey: "insights.coach.metric.resting_hr",
|
||||
label: "Resting HR",
|
||||
value: { metric: "resting_hr" as const },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function collect() {
|
||||
const sent: CoachReplyIntent[] = [];
|
||||
return { sent, onReply: (intent: CoachReplyIntent) => sent.push(intent) };
|
||||
}
|
||||
|
||||
describe("latestDecision", () => {
|
||||
it("is the latest answer's open question or proposal", () => {
|
||||
const msgs = [
|
||||
assistant("a1", {
|
||||
memoryNote: {
|
||||
proposal: true,
|
||||
proposalId: "p1",
|
||||
category: "medication",
|
||||
},
|
||||
}),
|
||||
];
|
||||
expect(latestDecision(msgs, undefined)).toEqual({
|
||||
messageId: "a1",
|
||||
clarification: null,
|
||||
memoryProposalId: "p1",
|
||||
planId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("is gone once the person has answered", () => {
|
||||
const msgs = [assistant("a1", { clarification: CLARIFY }), user("u2")];
|
||||
expect(latestDecision(msgs, undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("offers nothing while a turn runs, and nothing for a saved note", () => {
|
||||
const msgs = [assistant("a1", { clarification: CLARIFY })];
|
||||
expect(
|
||||
latestDecision(msgs, { inProgress: true } as CoachStreamingMessage),
|
||||
).toBeNull();
|
||||
expect(
|
||||
latestDecision(
|
||||
[
|
||||
assistant("a1", {
|
||||
memoryNote: { proposal: false, factId: "f1", category: "goal" },
|
||||
}),
|
||||
],
|
||||
undefined,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("reads the just-streamed turn before its persisted copy lands", () => {
|
||||
const streaming = {
|
||||
inProgress: false,
|
||||
messageId: "a9",
|
||||
clarification: null,
|
||||
memoryNote: null,
|
||||
planProposal: {
|
||||
planId: "plan1",
|
||||
metric: "WEIGHT",
|
||||
reviewInDays: 14,
|
||||
ifCue: "after dinner",
|
||||
thenAction: "walk",
|
||||
},
|
||||
} as unknown as CoachStreamingMessage;
|
||||
expect(latestDecision([], streaming)?.planId).toBe("plan1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("decisionReplies", () => {
|
||||
it("puts the assumed choice first and sends it as a clarification", () => {
|
||||
const { sent, onReply } = collect();
|
||||
const replies = decisionReplies(
|
||||
{
|
||||
messageId: "a1",
|
||||
clarification: CLARIFY,
|
||||
memoryProposalId: null,
|
||||
planId: null,
|
||||
},
|
||||
t,
|
||||
onReply,
|
||||
);
|
||||
expect(replies.map((r) => r.label)).toEqual(["Resting HR", "Pulse"]);
|
||||
replies[0].onSelect();
|
||||
expect(sent[0]).toMatchObject({
|
||||
kind: "clarification",
|
||||
messageId: "a1",
|
||||
choice: { id: "c2" },
|
||||
label: "Resting HR",
|
||||
});
|
||||
});
|
||||
|
||||
it("confirms a fact proposal with Yes and declines it with No", () => {
|
||||
const { sent, onReply } = collect();
|
||||
const replies = decisionReplies(
|
||||
{
|
||||
messageId: "a1",
|
||||
clarification: null,
|
||||
memoryProposalId: "p1",
|
||||
planId: null,
|
||||
},
|
||||
t,
|
||||
onReply,
|
||||
);
|
||||
expect(replies.map((r) => r.label)).toEqual(["Yes, remember it", "No"]);
|
||||
replies[0].onSelect();
|
||||
replies[1].onSelect();
|
||||
expect(sent).toEqual([
|
||||
{
|
||||
kind: "memory",
|
||||
messageId: "a1",
|
||||
proposalId: "p1",
|
||||
accept: true,
|
||||
label: "Yes, remember it",
|
||||
},
|
||||
{
|
||||
kind: "memory",
|
||||
messageId: "a1",
|
||||
proposalId: "p1",
|
||||
accept: false,
|
||||
label: "No",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("takes on or postpones a plan", () => {
|
||||
const { sent, onReply } = collect();
|
||||
const replies = decisionReplies(
|
||||
{
|
||||
messageId: "a1",
|
||||
clarification: null,
|
||||
memoryProposalId: null,
|
||||
planId: "plan1",
|
||||
},
|
||||
getServerTranslator("de").t,
|
||||
onReply,
|
||||
);
|
||||
expect(replies.map((r) => r.label)).toEqual([
|
||||
"Plan übernehmen",
|
||||
"Nicht jetzt",
|
||||
]);
|
||||
replies[1].onSelect();
|
||||
expect(sent[0]).toEqual({
|
||||
kind: "plan",
|
||||
messageId: "a1",
|
||||
planId: "plan1",
|
||||
accept: false,
|
||||
label: "Nicht jetzt",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* v1.41 — the compare chart: two periods overlaid on one axis, or two
|
||||
* metrics with two axes whose grid lines meet. Recharts is replaced by
|
||||
* pass-through parts so the props the chart hands it can be read without a
|
||||
* browser layout.
|
||||
*/
|
||||
import type { ReactNode } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const calls = vi.hoisted(() => ({
|
||||
yAxis: [] as Array<Record<string, unknown>>,
|
||||
line: [] as Array<Record<string, unknown>>,
|
||||
}));
|
||||
|
||||
vi.mock("recharts", () => {
|
||||
const Pass = ({ children }: { children?: ReactNode }) => <>{children}</>;
|
||||
const Leaf = () => null;
|
||||
return {
|
||||
ResponsiveContainer: Pass,
|
||||
LineChart: Pass,
|
||||
BarChart: Pass,
|
||||
CartesianGrid: Leaf,
|
||||
Bar: Leaf,
|
||||
Tooltip: Leaf,
|
||||
XAxis: Leaf,
|
||||
Line: (props: Record<string, unknown>) => {
|
||||
calls.line.push(props);
|
||||
return null;
|
||||
},
|
||||
YAxis: (props: Record<string, unknown>) => {
|
||||
calls.yAxis.push(props);
|
||||
return null;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { CoachChartSpec, CoachResultTable } from "@/lib/ai/coach/types";
|
||||
|
||||
import { CoachResultChart, alignedAxes, comparePoints } from "../result-chart";
|
||||
|
||||
function table(
|
||||
chart: Extract<CoachChartSpec, { kind: "compare" }>,
|
||||
rows: CoachResultTable["rows"],
|
||||
labels: [string, string],
|
||||
): CoachResultTable {
|
||||
return {
|
||||
ref: "r1",
|
||||
source: {
|
||||
tool: "compare_series" as never,
|
||||
domain: "resting_hr",
|
||||
window: "last30days",
|
||||
period: "current",
|
||||
},
|
||||
shape: "timeSeries",
|
||||
titleKey: "k",
|
||||
title: "Resting heart rate and sleep",
|
||||
rowCount: rows.length,
|
||||
chartKind: "compare",
|
||||
displayed: true,
|
||||
columns: [
|
||||
{ key: "period", kind: "category", labelKey: "k", label: "Day" },
|
||||
{
|
||||
key: "a",
|
||||
kind: "number",
|
||||
labelKey: "k",
|
||||
label: labels[0],
|
||||
unit: "bpm",
|
||||
},
|
||||
{ key: "b", kind: "number", labelKey: "k", label: labels[1], unit: "h" },
|
||||
],
|
||||
rows,
|
||||
truncated: false,
|
||||
chart,
|
||||
};
|
||||
}
|
||||
|
||||
const METRICS = table(
|
||||
{ kind: "compare", mode: "metrics", x: "period", a: "a", b: "b", axes: 2 },
|
||||
[
|
||||
["1", 58, 7.5],
|
||||
["2", 61, 6.1],
|
||||
["3", null, 6.8],
|
||||
["4", 59, 8.0],
|
||||
],
|
||||
["Resting HR", "Sleep"],
|
||||
);
|
||||
const PERIODS = table(
|
||||
{ kind: "compare", mode: "periods", x: "period", a: "a", b: "b", axes: 1 },
|
||||
[
|
||||
["1", 58, 60],
|
||||
["2", 61, 62],
|
||||
],
|
||||
["This month", "Last month"],
|
||||
);
|
||||
|
||||
function render(result: CoachResultTable) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<CoachResultChart result={result} otherLabel="Other" label="Chart" />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
calls.yAxis.length = 0;
|
||||
calls.line.length = 0;
|
||||
});
|
||||
|
||||
describe("compare chart", () => {
|
||||
it("renders, where the placeholder rendered nothing", () => {
|
||||
const html = render(METRICS);
|
||||
expect(html).toContain('data-slot="coach-result-chart-compare"');
|
||||
expect(html).toContain('role="img"');
|
||||
// A legend button per series, both shown.
|
||||
expect(html.match(/aria-pressed="true"/g)).toHaveLength(2);
|
||||
expect(html).toContain("Resting HR");
|
||||
expect(html).toContain("Sleep");
|
||||
});
|
||||
|
||||
it("draws two metrics on two axes whose grid lines meet", () => {
|
||||
render(METRICS);
|
||||
const [left, right] = calls.yAxis;
|
||||
expect(left.yAxisId).toBe("left");
|
||||
expect(right.yAxisId).toBe("right");
|
||||
expect(right.orientation).toBe("right");
|
||||
expect((left.ticks as number[]).length).toBe(
|
||||
(right.ticks as number[]).length,
|
||||
);
|
||||
expect(calls.line.map((l) => [l.dataKey, l.yAxisId, l.stroke])).toEqual([
|
||||
["a", "left", "var(--chart-1)"],
|
||||
["b", "right", "var(--chart-2)"],
|
||||
]);
|
||||
// A gap stays a gap.
|
||||
expect(calls.line.every((l) => l.connectNulls === false)).toBe(true);
|
||||
});
|
||||
|
||||
it("overlays two periods on one axis, the earlier one dashed, named a vs b", () => {
|
||||
const html = render(PERIODS);
|
||||
expect(calls.yAxis).toHaveLength(1);
|
||||
expect(calls.line.map((l) => l.strokeDasharray)).toEqual([
|
||||
undefined,
|
||||
"5 4",
|
||||
]);
|
||||
expect(html).toContain('aria-label="This month vs Last month"');
|
||||
});
|
||||
|
||||
it("reads its rows by the spec's columns", () => {
|
||||
expect(comparePoints(METRICS, METRICS.chart as never)[2]).toEqual({
|
||||
x: "3",
|
||||
a: null,
|
||||
b: 6.8,
|
||||
});
|
||||
});
|
||||
|
||||
it("widens the second axis until its ticks line up with the first", () => {
|
||||
const axes = alignedAxes([50, 90], [6, 7]);
|
||||
expect(axes).not.toBeNull();
|
||||
expect(axes?.right.ticks.length).toBe(axes?.left.ticks.length);
|
||||
const steps = axes!.right.ticks
|
||||
.slice(1)
|
||||
.map((v, i) => v - axes!.right.ticks[i]);
|
||||
expect(new Set(steps.map((s) => s.toFixed(6))).size).toBe(1);
|
||||
expect(alignedAxes([], [1])).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* v1.41 — the one quiet reply pattern: pills under the latest answer that
|
||||
* send their label as the person's own message.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { isValidElement, type ReactElement, type ReactNode } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
|
||||
vi.mock("@/lib/i18n/context", () => ({
|
||||
useTranslations: () => ({
|
||||
t: (key: string, params?: Record<string, string | number>) =>
|
||||
getServerTranslator("en").t(key, params),
|
||||
}),
|
||||
}));
|
||||
|
||||
const focusComposer = vi.fn();
|
||||
vi.mock("../composer-focus", () => ({
|
||||
focusCoachComposer: () => focusComposer(),
|
||||
}));
|
||||
|
||||
import { SuggestedReplies, type SuggestedReply } from "../suggested-replies";
|
||||
|
||||
function pills(node: ReactNode): ReactElement<Record<string, unknown>>[] {
|
||||
if (Array.isArray(node)) return node.flatMap(pills);
|
||||
if (!isValidElement(node)) return [];
|
||||
const el = node as ReactElement<Record<string, unknown>>;
|
||||
const own = el.props["data-reply-id"] ? [el] : [];
|
||||
return [...own, ...pills(el.props.children as ReactNode)];
|
||||
}
|
||||
|
||||
function replies(onSelect: (id: string) => void): SuggestedReply[] {
|
||||
return [
|
||||
"Resting heart rate",
|
||||
"Walking heart rate",
|
||||
"Both",
|
||||
"Neither",
|
||||
"Fifth",
|
||||
].map((label, i) => ({
|
||||
id: `c${i + 1}`,
|
||||
label,
|
||||
kind: "clarification",
|
||||
data: { "data-choice-id": `c${i + 1}` },
|
||||
onSelect: () => onSelect(`c${i + 1}`),
|
||||
}));
|
||||
}
|
||||
|
||||
describe("<SuggestedReplies>", () => {
|
||||
it("renders at most four quiet outline pills in a labelled group", () => {
|
||||
const tree = SuggestedReplies({
|
||||
replies: replies(() => {}),
|
||||
messageId: "m1",
|
||||
disabled: false,
|
||||
});
|
||||
const html = tree ? renderToStaticMarkup(tree) : "";
|
||||
expect(html).toContain('role="group"');
|
||||
expect(html).toContain('aria-label="Suggested replies"');
|
||||
expect(html).toContain('data-slot="coach-suggested-replies"');
|
||||
expect(html).toContain('data-message-id="m1"');
|
||||
expect(html).not.toContain("Fifth");
|
||||
const rendered = pills(tree);
|
||||
expect(rendered).toHaveLength(4);
|
||||
for (const pill of rendered) {
|
||||
expect(pill.props.variant).toBe("outline");
|
||||
const cls = String(pill.props.className);
|
||||
// The answer's text size, low contrast in the foreground, a pill.
|
||||
expect(cls).toContain("text-sm");
|
||||
expect(cls).toContain("font-normal");
|
||||
expect(cls).toContain("text-foreground/80");
|
||||
expect(cls).toContain("rounded-full");
|
||||
expect(cls).toContain("min-h-11");
|
||||
// Never an accent, never muted-with-alpha.
|
||||
expect(cls).not.toMatch(/\bprimary\b|text-muted-foreground\/\d/);
|
||||
}
|
||||
});
|
||||
|
||||
it("marks keyboard focus with the neutral ring, never the purple one", () => {
|
||||
const tree = SuggestedReplies({
|
||||
replies: replies(() => {}),
|
||||
messageId: "m1",
|
||||
disabled: false,
|
||||
});
|
||||
const html = tree ? renderToStaticMarkup(tree) : "";
|
||||
// The class the browser gets, after the primitive's variant classes and
|
||||
// ours are merged.
|
||||
const classes = [...html.matchAll(/<button[^>]*class="([^"]*)"/g)].map(
|
||||
(m) => m[1],
|
||||
);
|
||||
expect(classes).toHaveLength(4);
|
||||
for (const cls of classes) {
|
||||
expect(cls).toContain("focus-visible:ring-input-focus");
|
||||
expect(cls).toContain("focus-visible:border-border");
|
||||
expect(cls).toContain("focus-visible:ring-2");
|
||||
expect(cls).not.toMatch(
|
||||
/(?:^|\s)focus-visible:(?:ring-ring|border-ring|ring-\[3px\])/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("sends the tapped reply and hands focus to the composer", () => {
|
||||
focusComposer.mockClear();
|
||||
const onSelect = vi.fn();
|
||||
const tree = SuggestedReplies({
|
||||
replies: replies(onSelect),
|
||||
messageId: "m1",
|
||||
disabled: false,
|
||||
});
|
||||
(pills(tree)[1].props.onClick as () => void)();
|
||||
expect(onSelect).toHaveBeenCalledWith("c2");
|
||||
expect(focusComposer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("is hidden while a turn runs, and when there is nothing to offer", () => {
|
||||
expect(
|
||||
SuggestedReplies({
|
||||
replies: replies(() => {}),
|
||||
messageId: "m1",
|
||||
disabled: true,
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
SuggestedReplies({ replies: [], messageId: "m1", disabled: false }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,691 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
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 { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { pluralKey } from "@/lib/i18n/plural";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import type { CoachActivity, CoachStep } from "@/lib/ai/coach/types";
|
||||
|
||||
import { Brain, ChartScatter, Database, Lightbulb, Table2 } from "lucide-react";
|
||||
|
||||
import {
|
||||
CoachTurnActivity,
|
||||
activityLineLabel,
|
||||
currentActivity,
|
||||
currentStep,
|
||||
describeStep,
|
||||
inProgressLabel,
|
||||
legacyAreaLabels,
|
||||
openAfter,
|
||||
stepIcon,
|
||||
stepLabel,
|
||||
} from "../turn-activity";
|
||||
|
||||
function render(node: React.ReactNode, locale: Locale = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function translators(locale: Locale) {
|
||||
const { t } = getServerTranslator(locale);
|
||||
const tCount = (base: string, count: number) =>
|
||||
t(pluralKey(base, count, locale), { count });
|
||||
return { t, tCount };
|
||||
}
|
||||
|
||||
const BP: CoachStep = {
|
||||
id: "s1",
|
||||
tool: "get_metric_series",
|
||||
labelKey: "coach.step.readWindow",
|
||||
label: "Checking: Blood pressure, last 90 days",
|
||||
domain: "bp",
|
||||
window: "last90days",
|
||||
status: "done",
|
||||
count: 142,
|
||||
};
|
||||
const SLEEP: CoachStep = {
|
||||
id: "s2",
|
||||
tool: "get_sleep",
|
||||
labelKey: "coach.step.readWindow",
|
||||
label: "Checking: Sleep, last 30 days",
|
||||
domain: "sleep",
|
||||
window: "last30days",
|
||||
status: "empty",
|
||||
reason: "no_data",
|
||||
};
|
||||
const LABS_RUNNING: CoachStep = {
|
||||
id: "s3",
|
||||
tool: "get_labs",
|
||||
labelKey: "coach.step.readWindow",
|
||||
label: "Checking: Lab results, last 12 months",
|
||||
domain: "labs",
|
||||
window: "lastYear",
|
||||
status: "running",
|
||||
};
|
||||
|
||||
describe("describeStep", () => {
|
||||
const { t, tCount } = translators("en");
|
||||
|
||||
it("reads domain · window · count on a found step", () => {
|
||||
expect(describeStep(BP, t, tCount)).toEqual({
|
||||
title: "Blood pressure",
|
||||
meta: ["last 90 days", "142 readings"],
|
||||
});
|
||||
});
|
||||
|
||||
it("says why nothing was found on an empty step", () => {
|
||||
expect(describeStep(SLEEP, t, tCount)).toEqual({
|
||||
title: "Sleep",
|
||||
meta: ["last 30 days", "no readings"],
|
||||
});
|
||||
});
|
||||
|
||||
it("shows no count on a miss, even when the record holds rows elsewhere", () => {
|
||||
const outside: CoachStep = {
|
||||
...SLEEP,
|
||||
reason: "outside_window",
|
||||
count: 40,
|
||||
};
|
||||
expect(describeStep(outside, t, tCount).meta).toEqual([
|
||||
"last 30 days",
|
||||
"no readings in this window",
|
||||
]);
|
||||
});
|
||||
|
||||
it("counts metrics on the snapshot step and rows on labs", () => {
|
||||
const snapshot: CoachStep = {
|
||||
id: "s1",
|
||||
tool: "snapshot",
|
||||
labelKey: "coach.step.snapshot",
|
||||
label: "Reading your record summary",
|
||||
domain: "snapshot",
|
||||
status: "done",
|
||||
count: 1,
|
||||
};
|
||||
expect(describeStep(snapshot, t, tCount)).toEqual({
|
||||
title: "Record summary",
|
||||
meta: ["1 metric"],
|
||||
});
|
||||
expect(
|
||||
describeStep({ ...LABS_RUNNING, status: "done", count: 3 }, t, tCount)
|
||||
.meta,
|
||||
).toEqual(["last 12 months", "3 rows"]);
|
||||
});
|
||||
|
||||
it("renders in the reader's language from the keys, not the server label", () => {
|
||||
const de = translators("de");
|
||||
expect(stepLabel(BP, de.t)).toBe("Prüfe: Blutdruck, letzte 90 Tage");
|
||||
expect(describeStep(BP, de.t, de.tCount).meta).toEqual([
|
||||
"letzte 90 Tage",
|
||||
"142 Messwerte",
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to the server label for a key the bundle lacks", () => {
|
||||
expect(stepLabel({ ...BP, labelKey: "coach.step.fromTheFuture" }, t)).toBe(
|
||||
BP.label,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const THINKING_RUNNING: CoachActivity = {
|
||||
id: "a1",
|
||||
phase: "thinking",
|
||||
status: "running",
|
||||
round: 1,
|
||||
labelKey: "insights.coach.activity.thinking",
|
||||
label: "Thinking…",
|
||||
};
|
||||
const FETCH_BP: CoachActivity = {
|
||||
id: "a2",
|
||||
phase: "fetch",
|
||||
status: "done",
|
||||
round: 1,
|
||||
labelKey: "insights.coach.activity.fetching",
|
||||
label: "Fetching blood pressure, last 90 days…",
|
||||
stepRef: "s1",
|
||||
durationMs: 900,
|
||||
};
|
||||
const FETCH_SLEEP: CoachActivity = {
|
||||
id: "a3",
|
||||
phase: "fetch",
|
||||
status: "empty",
|
||||
round: 1,
|
||||
labelKey: "insights.coach.activity.fetching",
|
||||
label: "Fetching sleep, last 30 days…",
|
||||
stepRef: "s2",
|
||||
durationMs: 1_400,
|
||||
};
|
||||
const DIGEST_RUNNING: CoachActivity = {
|
||||
id: "a4",
|
||||
phase: "digest",
|
||||
status: "running",
|
||||
round: 1,
|
||||
labelKey: "insights.coach.activity.digestOther",
|
||||
label: "Summarising 214 readings…",
|
||||
count: 214,
|
||||
};
|
||||
const THINKING_DONE: CoachActivity = {
|
||||
...THINKING_RUNNING,
|
||||
status: "done",
|
||||
durationMs: 4_000,
|
||||
title: "Comparing the two months",
|
||||
text: "The second half of the month runs higher.",
|
||||
};
|
||||
const DIGEST_DONE: CoachActivity = {
|
||||
...DIGEST_RUNNING,
|
||||
status: "done",
|
||||
label: "214 readings from 2 areas",
|
||||
durationMs: 600,
|
||||
};
|
||||
const ANSWER_DONE: CoachActivity = {
|
||||
id: "a5",
|
||||
phase: "answer",
|
||||
status: "done",
|
||||
round: 2,
|
||||
labelKey: "insights.coach.activity.answer",
|
||||
label: "Writing the answer…",
|
||||
durationMs: 6_000,
|
||||
};
|
||||
const ASKING: CoachActivity = {
|
||||
id: "a6",
|
||||
phase: "asking",
|
||||
status: "done",
|
||||
round: 1,
|
||||
labelKey: "insights.coach.activity.asking",
|
||||
label: "Asking you…",
|
||||
durationMs: 1_000,
|
||||
};
|
||||
const STOPPED: CoachActivity = {
|
||||
id: "a7",
|
||||
phase: "stop",
|
||||
status: "done",
|
||||
round: 6,
|
||||
labelKey: "insights.coach.activity.stop.time",
|
||||
label: "Time limit reached, answering with what it has",
|
||||
stop: "time",
|
||||
};
|
||||
const SETTLED = [
|
||||
THINKING_DONE,
|
||||
FETCH_BP,
|
||||
FETCH_SLEEP,
|
||||
DIGEST_DONE,
|
||||
ANSWER_DONE,
|
||||
];
|
||||
|
||||
/** The row's element (button or plain row) and everything inside it. */
|
||||
function rowHtml(html: string): string {
|
||||
const match = html.match(
|
||||
/<(button|div)[^>]*data-slot="coach-turn-steps-(?:toggle|row)"[\s\S]*?<\/\1>/,
|
||||
);
|
||||
return match?.[0] ?? "";
|
||||
}
|
||||
|
||||
/** The visible text of a fragment, tags removed. */
|
||||
function textOf(html: string): string {
|
||||
let text = html.replace(/<span class="sr-only select-none">, <\/span>/g, "");
|
||||
for (let prev = ""; prev !== text;) {
|
||||
prev = text;
|
||||
text = text.replace(/<[^>]*>/g, "");
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
describe("one row while the Coach works", () => {
|
||||
it("walks thinking → fetching → summarising on one shimmering row", () => {
|
||||
const frames = [
|
||||
[THINKING_RUNNING],
|
||||
[
|
||||
{ ...THINKING_RUNNING, status: "done" as const },
|
||||
{ ...FETCH_BP, status: "running" as const },
|
||||
],
|
||||
[THINKING_DONE, FETCH_BP, DIGEST_RUNNING],
|
||||
];
|
||||
const htmls = frames.map((activity) =>
|
||||
render(<CoachTurnActivity activity={activity} steps={[]} active />),
|
||||
);
|
||||
expect(htmls[0]).toContain('data-text="Thinking…"');
|
||||
expect(htmls[1]).toContain(
|
||||
'data-text="Fetching blood pressure, last 90 days…"',
|
||||
);
|
||||
expect(htmls[2]).toContain('data-text="Summarising 214 readings…"');
|
||||
for (const html of htmls) {
|
||||
// Exactly one row, and nothing under it while closed.
|
||||
expect(
|
||||
html.match(/data-slot="coach-turn-steps-(toggle|row)"/g),
|
||||
).toHaveLength(1);
|
||||
expect(html).toContain('data-state="running"');
|
||||
expect(html).not.toContain("coach-interim");
|
||||
expect(html).not.toContain('<svg viewBox="0 0 48 16"');
|
||||
// The words shimmer; the trailing dots fade on their own.
|
||||
expect(html).toMatch(/<span class="text-shimmer">[^<]+<\/span>/);
|
||||
expect(html).toContain('data-slot="waiting-dots"');
|
||||
expect(html.match(/class="waiting-dot /g)).toHaveLength(3);
|
||||
expect(html).toContain("[animation-delay:0.2s]");
|
||||
expect(html).toContain("[animation-delay:0.4s]");
|
||||
// No clock while it runs, in any form.
|
||||
expect(html).not.toMatch(/\d+\s?s</);
|
||||
expect(html).not.toContain("coach-turn-steps-seconds");
|
||||
expect(html).not.toContain("tabular-nums");
|
||||
}
|
||||
});
|
||||
|
||||
it("reads brain, text, chevron, in the answer's text size and the muted tone", () => {
|
||||
const html = render(
|
||||
<CoachTurnActivity
|
||||
activity={[THINKING_DONE, FETCH_BP, DIGEST_RUNNING]}
|
||||
steps={[BP]}
|
||||
active
|
||||
/>,
|
||||
);
|
||||
const row = rowHtml(html);
|
||||
expect(row.startsWith("<button")).toBe(true);
|
||||
const order = [
|
||||
row.indexOf("lucide-brain"),
|
||||
row.indexOf('data-slot="coach-turn-steps-active"'),
|
||||
row.indexOf('data-slot="coach-turn-steps-chevron"'),
|
||||
];
|
||||
expect(order.every((at) => at > -1)).toBe(true);
|
||||
expect([...order].sort((a, b) => a - b)).toEqual(order);
|
||||
expect(row).toContain("lucide-chevron-down");
|
||||
expect(row).toMatch(/class="[^"]*\bw-fit\b/);
|
||||
expect(row).toMatch(/class="[^"]*\btext-muted-foreground\b/);
|
||||
expect(row).toMatch(/class="[^"]*\bhover:text-foreground\b/);
|
||||
expect(row).toContain("focus-visible:ring-input-focus");
|
||||
expect(row).not.toMatch(/ring-ring|ring-primary|text-primary/);
|
||||
expect(html).toMatch(
|
||||
/data-slot="coach-turn-steps"[^>]*class="[^"]*\btext-sm\b/,
|
||||
);
|
||||
expect(html).not.toMatch(
|
||||
/data-slot="coach-turn-steps"[^>]*class="[^"]*\btext-xs\b/,
|
||||
);
|
||||
// A stable name while the text keeps changing.
|
||||
expect(row).toContain('aria-label="Show what the Coach looked at"');
|
||||
expect(row).toContain('aria-expanded="false"');
|
||||
});
|
||||
|
||||
it("says Thinking… before the first frame, with no chevron and nothing to open", () => {
|
||||
const html = render(<CoachTurnActivity activity={[]} steps={[]} active />);
|
||||
expect(html).toContain('data-text="Thinking…"');
|
||||
expect(html).toContain("text-shimmer");
|
||||
expect(html).not.toContain("coach-turn-steps-chevron");
|
||||
expect(html).not.toContain("aria-expanded");
|
||||
expect(rowHtml(html).startsWith("<div")).toBe(true);
|
||||
});
|
||||
|
||||
it("names a reasoning title as still going, and a step-only server's step", () => {
|
||||
expect(
|
||||
render(
|
||||
<CoachTurnActivity
|
||||
activity={[{ ...THINKING_RUNNING, title: "Checking the trend" }]}
|
||||
steps={[]}
|
||||
active
|
||||
/>,
|
||||
),
|
||||
).toContain('data-text="Checking the trend…"');
|
||||
expect(
|
||||
render(
|
||||
<CoachTurnActivity activity={[]} steps={[BP, LABS_RUNNING]} active />,
|
||||
),
|
||||
).toContain('data-text="Checking: Lab results, last 12 months…"');
|
||||
});
|
||||
|
||||
it("reads in German", () => {
|
||||
const html = render(
|
||||
<CoachTurnActivity activity={[]} steps={[]} active />,
|
||||
"de",
|
||||
);
|
||||
expect(html).toContain('data-text="Denkt nach…"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("the labels", () => {
|
||||
it("names the latest running entry, else the latest one", () => {
|
||||
expect(currentActivity([THINKING_DONE, FETCH_BP, DIGEST_RUNNING])?.id).toBe(
|
||||
"a4",
|
||||
);
|
||||
expect(currentActivity([THINKING_DONE, FETCH_BP])?.id).toBe("a2");
|
||||
expect(currentActivity([])).toBeNull();
|
||||
});
|
||||
|
||||
it("shows a reasoning title once one arrives, the catalog label before", () => {
|
||||
expect(activityLineLabel(THINKING_RUNNING)).toBe("Thinking…");
|
||||
expect(
|
||||
activityLineLabel({ ...THINKING_RUNNING, title: "Checking the trend" }),
|
||||
).toBe("Checking the trend");
|
||||
// Model text never replaces a fetch's catalog label, and a finished
|
||||
// entry no longer trails off.
|
||||
expect(activityLineLabel({ ...FETCH_BP, title: "x" })).toBe(
|
||||
"Fetching blood pressure, last 90 days",
|
||||
);
|
||||
expect(
|
||||
activityLineLabel({ ...FETCH_BP, status: "running", title: "x" }),
|
||||
).toBe("Fetching blood pressure, last 90 days…");
|
||||
});
|
||||
|
||||
it("ends a running line in exactly one ellipsis", () => {
|
||||
expect(inProgressLabel("Writing the answer")).toBe("Writing the answer…");
|
||||
expect(inProgressLabel("Writing the answer…")).toBe("Writing the answer…");
|
||||
expect(inProgressLabel("Writing the answer...")).toBe(
|
||||
"Writing the answer…",
|
||||
);
|
||||
});
|
||||
|
||||
it("picks an icon by the kind of work", () => {
|
||||
expect(stepIcon("thinking", undefined)).toBe(Lightbulb);
|
||||
expect(stepIcon("fetch", "get_metric_series")).toBe(Database);
|
||||
expect(stepIcon("fetch", "get_metric_table")).toBe(Table2);
|
||||
expect(stepIcon("fetch", "get_correlations")).toBe(ChartScatter);
|
||||
expect(stepIcon(null, "get_sleep")).toBe(Database);
|
||||
// The brain belongs to the row, never to a step.
|
||||
for (const phase of [
|
||||
"thinking",
|
||||
"memory",
|
||||
"fetch",
|
||||
"digest",
|
||||
"checkpoint",
|
||||
"remember",
|
||||
"plan",
|
||||
"asking",
|
||||
"stop",
|
||||
"answer",
|
||||
] as const) {
|
||||
expect(stepIcon(phase, undefined)).not.toBe(Brain);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("after the answer", () => {
|
||||
it("settles into the thought process and its step count, without shimmer", () => {
|
||||
const html = render(
|
||||
<CoachTurnActivity
|
||||
activity={SETTLED}
|
||||
steps={[BP, SLEEP]}
|
||||
active={false}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-state="done"');
|
||||
expect(html).toMatch(
|
||||
/data-slot="coach-turn-steps-done"[^>]*>Thought process · 5 steps</,
|
||||
);
|
||||
expect(html).not.toContain("text-shimmer");
|
||||
expect(html).not.toContain("waiting-dot");
|
||||
expect(html).toContain('data-slot="coach-turn-steps-chevron"');
|
||||
expect(html).not.toMatch(/\d+\s?s</);
|
||||
// The settled row names itself by its text.
|
||||
expect(html).not.toContain('aria-label="Show what the Coach looked at"');
|
||||
});
|
||||
|
||||
it("counts in the reader's language, with proper plurals", () => {
|
||||
const de = render(
|
||||
<CoachTurnActivity activity={[ANSWER_DONE]} steps={[]} active={false} />,
|
||||
"de",
|
||||
);
|
||||
expect(de).toContain("Denkprozess · 1 Schritt<");
|
||||
const pl = render(
|
||||
<CoachTurnActivity
|
||||
activity={[THINKING_DONE, FETCH_BP, ANSWER_DONE]}
|
||||
steps={[]}
|
||||
active={false}
|
||||
/>,
|
||||
"pl",
|
||||
);
|
||||
expect(pl).toContain("Tok myślenia · 3 kroki<");
|
||||
});
|
||||
|
||||
it("an older message counts its steps, or the areas it drew on", () => {
|
||||
expect(
|
||||
render(
|
||||
<CoachTurnActivity activity={[]} steps={[BP, SLEEP]} active={false} />,
|
||||
),
|
||||
).toContain("Thought process · 2 steps<");
|
||||
expect(
|
||||
render(
|
||||
<CoachTurnActivity
|
||||
activity={[]}
|
||||
steps={[]}
|
||||
active={false}
|
||||
areas={["bp"]}
|
||||
/>,
|
||||
),
|
||||
).toContain("Thought process · 1 step<");
|
||||
});
|
||||
|
||||
it("says an answer is needed while a question waits, without shimmer", () => {
|
||||
const html = render(
|
||||
<CoachTurnActivity
|
||||
activity={[THINKING_DONE, ASKING]}
|
||||
steps={[]}
|
||||
active={false}
|
||||
awaitingAnswer
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-state="awaiting"');
|
||||
expect(html).toMatch(
|
||||
/data-slot="coach-turn-steps-done"[^>]*>Answer needed</,
|
||||
);
|
||||
expect(html).not.toContain("text-shimmer");
|
||||
expect(
|
||||
render(
|
||||
<CoachTurnActivity
|
||||
activity={[THINKING_DONE, ASKING]}
|
||||
steps={[]}
|
||||
active={false}
|
||||
awaitingAnswer
|
||||
/>,
|
||||
"de",
|
||||
),
|
||||
).toContain(">Antwort nötig<");
|
||||
});
|
||||
|
||||
it("renders nothing for a settled message with nothing to show", () => {
|
||||
expect(
|
||||
render(<CoachTurnActivity activity={[]} steps={[]} active={false} />),
|
||||
).not.toContain("coach-turn-steps");
|
||||
});
|
||||
|
||||
it("carries a polite status region that starts empty", () => {
|
||||
const html = render(
|
||||
<CoachTurnActivity activity={SETTLED} steps={[]} active={false} />,
|
||||
);
|
||||
expect(html).toMatch(
|
||||
/<span role="status" aria-live="polite" aria-atomic="true" class="sr-only"><\/span>/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("nothing opens by itself", () => {
|
||||
const states: Array<[string, React.ReactNode]> = [
|
||||
[
|
||||
"before any frame",
|
||||
<CoachTurnActivity key="0" activity={[]} steps={[]} active />,
|
||||
],
|
||||
[
|
||||
"running",
|
||||
<CoachTurnActivity
|
||||
key="1"
|
||||
activity={[THINKING_DONE, FETCH_BP, DIGEST_RUNNING]}
|
||||
steps={[BP]}
|
||||
active
|
||||
/>,
|
||||
],
|
||||
[
|
||||
"asking",
|
||||
<CoachTurnActivity
|
||||
key="2"
|
||||
activity={[THINKING_DONE, ASKING]}
|
||||
steps={[]}
|
||||
active={false}
|
||||
awaitingAnswer
|
||||
/>,
|
||||
],
|
||||
[
|
||||
"stopped",
|
||||
<CoachTurnActivity
|
||||
key="3"
|
||||
activity={[...SETTLED, STOPPED]}
|
||||
steps={[BP, SLEEP]}
|
||||
active={false}
|
||||
/>,
|
||||
],
|
||||
[
|
||||
"settled",
|
||||
<CoachTurnActivity
|
||||
key="4"
|
||||
activity={SETTLED}
|
||||
steps={[BP, SLEEP]}
|
||||
active={false}
|
||||
method={{ entries: [], text: "Blood pressure, 9 readings." }}
|
||||
dataUsed={<div data-slot="coach-data-used" />}
|
||||
/>,
|
||||
],
|
||||
[
|
||||
"reloaded (metadata only)",
|
||||
<CoachTurnActivity
|
||||
key="5"
|
||||
activity={JSON.parse(
|
||||
JSON.stringify(
|
||||
SETTLED.map(({ title: _t, text: _x, ...meta }) => meta),
|
||||
),
|
||||
)}
|
||||
steps={[BP, SLEEP]}
|
||||
active={false}
|
||||
conversationId="c1"
|
||||
messageId="m1"
|
||||
/>,
|
||||
],
|
||||
[
|
||||
"legacy steps",
|
||||
<CoachTurnActivity
|
||||
key="6"
|
||||
activity={[]}
|
||||
steps={[BP, SLEEP]}
|
||||
active={false}
|
||||
/>,
|
||||
],
|
||||
];
|
||||
for (const [name, node] of states) {
|
||||
it(`is closed when ${name}`, () => {
|
||||
const html = render(node);
|
||||
expect(html).not.toContain('aria-expanded="true"');
|
||||
expect(html).not.toContain('rotate-180"');
|
||||
expect(html).not.toContain('data-slot="coach-turn-steps-panel"');
|
||||
expect(html).not.toContain('data-slot="coach-turn-step-detail"');
|
||||
expect(html).not.toContain("The second half of the month runs higher.");
|
||||
});
|
||||
}
|
||||
|
||||
it("a row opened while the turn ran closes when it ends, and nothing opens it", () => {
|
||||
// Opened during the run, the turn ends: closed.
|
||||
expect(openAfter({ open: true, wasActive: true, active: false })).toBe(
|
||||
false,
|
||||
);
|
||||
// Closed stays closed through every move.
|
||||
for (const [wasActive, active] of [
|
||||
[true, false],
|
||||
[false, true],
|
||||
[true, true],
|
||||
[false, false],
|
||||
] as const) {
|
||||
expect(openAfter({ open: false, wasActive, active })).toBe(false);
|
||||
}
|
||||
// A tap on a settled row is the person's, and stays.
|
||||
expect(openAfter({ open: true, wasActive: false, active: false })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tokens and motion", () => {
|
||||
it("uses theme tokens only, and every motion is guarded or turned off by reduced motion", () => {
|
||||
const html = render(
|
||||
<CoachTurnActivity
|
||||
activity={[THINKING_DONE, FETCH_BP, DIGEST_RUNNING]}
|
||||
steps={[BP]}
|
||||
active
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toMatch(/#[0-9a-f]{3,8}\b|dracula-|\[#|oklch\(/i);
|
||||
expect(html).not.toMatch(/text-muted-foreground\/\d/);
|
||||
const classes = [...html.matchAll(/class="([^"]*)"/g)].flatMap((m) =>
|
||||
m[1].split(/\s+/),
|
||||
);
|
||||
for (const cls of classes) {
|
||||
if (cls.startsWith("transition") || cls.startsWith("animate-")) {
|
||||
throw new Error(`unguarded motion: ${cls}`);
|
||||
}
|
||||
}
|
||||
// A 44 px tap target on phones.
|
||||
expect(html).toContain("min-h-11");
|
||||
});
|
||||
|
||||
it("the shimmer and the dots stop under reduced motion, in the stylesheet", () => {
|
||||
const css = readFileSync(
|
||||
join(__dirname, "../../../../app/globals.css"),
|
||||
"utf8",
|
||||
);
|
||||
const shimmer =
|
||||
css.match(/@utility text-shimmer \{[\s\S]*?\n\}/)?.[0] ?? "";
|
||||
expect(shimmer).toContain("background-clip: text");
|
||||
expect(shimmer).toContain("background-size: 250% 100%");
|
||||
expect(shimmer).toContain("animation: text-shimmer 2s linear infinite");
|
||||
expect(shimmer).toMatch(
|
||||
/@media \(prefers-reduced-motion: reduce\) \{\s*animation: none;\s*background-image: none;\s*color: var\(--muted-foreground\);/,
|
||||
);
|
||||
expect(css).toMatch(
|
||||
/@keyframes text-shimmer \{\s*from \{\s*background-position: 100% 0;\s*\}\s*to \{\s*background-position: 0% 0;/,
|
||||
);
|
||||
const dot = css.match(/@utility waiting-dot \{[\s\S]*?\n\}/)?.[0] ?? "";
|
||||
expect(dot).toContain("animation: waiting-dot 1.4s ease-in-out infinite");
|
||||
expect(dot).toMatch(
|
||||
/@media \(prefers-reduced-motion: reduce\) \{\s*animation: none;\s*opacity: 1;/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("currentStep", () => {
|
||||
it("names the latest running step, else the latest step", () => {
|
||||
expect(currentStep([BP, LABS_RUNNING, SLEEP])?.id).toBe("s3");
|
||||
expect(currentStep([BP, SLEEP])?.id).toBe("s2");
|
||||
expect(currentStep([])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the visible row text", () => {
|
||||
it("is the status alone", () => {
|
||||
expect(
|
||||
textOf(
|
||||
rowHtml(
|
||||
render(
|
||||
<CoachTurnActivity activity={SETTLED} steps={[]} active={false} />,
|
||||
),
|
||||
),
|
||||
),
|
||||
).toBe("Thought process · 5 steps");
|
||||
expect(
|
||||
textOf(
|
||||
rowHtml(render(<CoachTurnActivity activity={[]} steps={[]} active />)),
|
||||
),
|
||||
).toBe("Thinking...");
|
||||
});
|
||||
});
|
||||
@@ -1,351 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { pluralKey } from "@/lib/i18n/plural";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import type { CoachStep } from "@/lib/ai/coach/types";
|
||||
|
||||
import {
|
||||
CoachTurnStepList,
|
||||
CoachTurnSteps,
|
||||
CoachTurnStepsPanel,
|
||||
countSources,
|
||||
legacyAreaLabels,
|
||||
currentStep,
|
||||
describeStep,
|
||||
nextAnnouncement,
|
||||
stepLabel,
|
||||
} from "../turn-steps";
|
||||
|
||||
function render(node: React.ReactNode, locale: Locale = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function translators(locale: Locale) {
|
||||
const { t } = getServerTranslator(locale);
|
||||
const tCount = (base: string, count: number) =>
|
||||
t(pluralKey(base, count, locale), { count });
|
||||
return { t, tCount };
|
||||
}
|
||||
|
||||
const BP: CoachStep = {
|
||||
id: "s1",
|
||||
tool: "get_metric_series",
|
||||
labelKey: "coach.step.readWindow",
|
||||
label: "Checking: Blood pressure, last 90 days",
|
||||
domain: "bp",
|
||||
window: "last90days",
|
||||
status: "done",
|
||||
count: 142,
|
||||
};
|
||||
const SLEEP: CoachStep = {
|
||||
id: "s2",
|
||||
tool: "get_sleep",
|
||||
labelKey: "coach.step.readWindow",
|
||||
label: "Checking: Sleep, last 30 days",
|
||||
domain: "sleep",
|
||||
window: "last30days",
|
||||
status: "empty",
|
||||
reason: "no_data",
|
||||
};
|
||||
const LABS_RUNNING: CoachStep = {
|
||||
id: "s3",
|
||||
tool: "get_labs",
|
||||
labelKey: "coach.step.readWindow",
|
||||
label: "Checking: Lab results, last 12 months",
|
||||
domain: "labs",
|
||||
window: "lastYear",
|
||||
status: "running",
|
||||
};
|
||||
|
||||
describe("describeStep", () => {
|
||||
const { t, tCount } = translators("en");
|
||||
|
||||
it("reads domain · window · count on a found step", () => {
|
||||
expect(describeStep(BP, t, tCount)).toEqual({
|
||||
title: "Blood pressure",
|
||||
meta: ["last 90 days", "142 readings"],
|
||||
});
|
||||
});
|
||||
|
||||
it("says why nothing was found on an empty step", () => {
|
||||
expect(describeStep(SLEEP, t, tCount)).toEqual({
|
||||
title: "Sleep",
|
||||
meta: ["last 30 days", "no readings"],
|
||||
});
|
||||
});
|
||||
|
||||
it("shows no count on a miss, even when the record holds rows elsewhere", () => {
|
||||
const outside: CoachStep = {
|
||||
...SLEEP,
|
||||
reason: "outside_window",
|
||||
count: 40,
|
||||
};
|
||||
expect(describeStep(outside, t, tCount).meta).toEqual([
|
||||
"last 30 days",
|
||||
"no readings in this window",
|
||||
]);
|
||||
});
|
||||
|
||||
it("counts metrics on the snapshot step and rows on labs", () => {
|
||||
const snapshot: CoachStep = {
|
||||
id: "s1",
|
||||
tool: "snapshot",
|
||||
labelKey: "coach.step.snapshot",
|
||||
label: "Reading your record summary",
|
||||
domain: "snapshot",
|
||||
status: "done",
|
||||
count: 1,
|
||||
};
|
||||
expect(describeStep(snapshot, t, tCount)).toEqual({
|
||||
title: "Record summary",
|
||||
meta: ["1 metric"],
|
||||
});
|
||||
expect(
|
||||
describeStep({ ...LABS_RUNNING, status: "done", count: 3 }, t, tCount)
|
||||
.meta,
|
||||
).toEqual(["last 12 months", "3 rows"]);
|
||||
});
|
||||
|
||||
it("renders in the reader's language from the keys, not the server label", () => {
|
||||
const de = translators("de");
|
||||
expect(stepLabel(BP, de.t)).toBe("Prüfe: Blutdruck, letzte 90 Tage");
|
||||
expect(describeStep(BP, de.t, de.tCount).meta).toEqual([
|
||||
"letzte 90 Tage",
|
||||
"142 Messwerte",
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to the server label for a key the bundle lacks", () => {
|
||||
expect(stepLabel({ ...BP, labelKey: "coach.step.fromTheFuture" }, t)).toBe(
|
||||
BP.label,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("currentStep / nextAnnouncement", () => {
|
||||
it("names the latest running step, else the latest step", () => {
|
||||
expect(currentStep([BP, LABS_RUNNING, SLEEP])?.id).toBe("s3");
|
||||
expect(currentStep([BP, SLEEP])?.id).toBe("s2");
|
||||
expect(currentStep([])).toBeNull();
|
||||
});
|
||||
|
||||
it("announces only settled steps not yet told, the newest one", () => {
|
||||
expect(nextAnnouncement([BP, SLEEP, LABS_RUNNING], new Set())?.id).toBe(
|
||||
"s2",
|
||||
);
|
||||
expect(nextAnnouncement([BP, SLEEP], new Set(["s1", "s2"]))).toBeNull();
|
||||
expect(nextAnnouncement([LABS_RUNNING], new Set())).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("<CoachTurnSteps>", () => {
|
||||
it("renders nothing without steps", () => {
|
||||
expect(render(<CoachTurnSteps steps={[]} active />)).not.toContain(
|
||||
"coach-turn-steps",
|
||||
);
|
||||
});
|
||||
|
||||
it("while active: the running step in the header, with a stable accessible name", () => {
|
||||
const html = render(<CoachTurnSteps steps={[BP, LABS_RUNNING]} active />);
|
||||
expect(html).toContain('data-slot="coach-turn-steps-active"');
|
||||
expect(html).toContain("Checking: Lab results, last 12 months…");
|
||||
expect(html).toContain('aria-label="Show what the Coach looked at"');
|
||||
expect(html).toContain('aria-expanded="false"');
|
||||
// The shimmer comes from the class that stops under reduced motion.
|
||||
expect(html).toContain("skeleton-shimmer");
|
||||
// Collapsed by default.
|
||||
expect(html).not.toContain('data-slot="coach-turn-steps-list"');
|
||||
});
|
||||
|
||||
it("when done: folds into a count of sources, closed", () => {
|
||||
const html = render(<CoachTurnSteps steps={[BP, SLEEP]} active={false} />);
|
||||
expect(html).toContain("Looked at 2 sources");
|
||||
expect(html).not.toContain("skeleton-shimmer");
|
||||
expect(html).not.toContain("aria-label=");
|
||||
expect(html).not.toContain('data-slot="coach-turn-steps-list"');
|
||||
});
|
||||
|
||||
it("counts distinct sources: a second read of the same domain is not a new source", () => {
|
||||
const bpPrevious: CoachStep = {
|
||||
...BP,
|
||||
id: "s4",
|
||||
period: "previous",
|
||||
count: 120,
|
||||
};
|
||||
const snapshot: CoachStep = {
|
||||
id: "s5",
|
||||
tool: "snapshot",
|
||||
labelKey: "coach.step.snapshot",
|
||||
label: "Overview",
|
||||
status: "done",
|
||||
};
|
||||
expect(countSources([BP, bpPrevious, SLEEP])).toBe(2);
|
||||
expect(countSources([BP, snapshot, snapshot])).toBe(2);
|
||||
const html = render(
|
||||
<CoachTurnSteps steps={[BP, bpPrevious, SLEEP]} active={false} />,
|
||||
);
|
||||
expect(html).toContain("Looked at 2 sources");
|
||||
});
|
||||
|
||||
it("carries a polite status region that starts empty", () => {
|
||||
const html = render(<CoachTurnSteps steps={[BP]} active={false} />);
|
||||
expect(html).toMatch(
|
||||
/<span role="status" aria-live="polite" aria-atomic="true" class="sr-only"><\/span>/,
|
||||
);
|
||||
});
|
||||
|
||||
it("uses theme tokens only, and every motion stops under reduced motion", () => {
|
||||
const html =
|
||||
render(<CoachTurnSteps steps={[BP, LABS_RUNNING]} active />) +
|
||||
render(
|
||||
<CoachTurnStepList
|
||||
steps={[
|
||||
BP,
|
||||
SLEEP,
|
||||
LABS_RUNNING,
|
||||
{
|
||||
...SLEEP,
|
||||
id: "s4",
|
||||
status: "failed",
|
||||
reason: "retrieval_failed",
|
||||
},
|
||||
]}
|
||||
active
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toMatch(/#[0-9a-f]{3,8}\b|dracula-|\[#|oklch\(/i);
|
||||
expect(html).not.toMatch(/text-muted-foreground\/\d/);
|
||||
const classes = [...html.matchAll(/class="([^"]*)"/g)].flatMap((m) =>
|
||||
m[1].split(/\s+/),
|
||||
);
|
||||
for (const cls of classes) {
|
||||
if (cls.startsWith("transition")) {
|
||||
throw new Error(`unguarded transition: ${cls}`);
|
||||
}
|
||||
}
|
||||
// The spinner stops under reduced motion (the repo-wide convention).
|
||||
expect(html).toContain("animate-spin motion-reduce:animate-none");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<CoachTurnStepList>", () => {
|
||||
it("one row per step: domain · window · count or reason, with its status", () => {
|
||||
const html = render(
|
||||
<CoachTurnStepList steps={[BP, SLEEP, LABS_RUNNING]} active />,
|
||||
);
|
||||
expect(html).toContain('aria-label="What the Coach looked at"');
|
||||
expect(html.match(/data-slot="coach-turn-step"/g)).toHaveLength(3);
|
||||
expect(html).toContain('data-status="done"');
|
||||
expect(html).toContain('data-status="empty"');
|
||||
expect(html).toContain('data-status="running"');
|
||||
let text = html.replace(/<span class="sr-only">, <\/span>/g, "");
|
||||
// Strip tags until nothing changes, so no fragment can survive one pass.
|
||||
for (let prev = ""; prev !== text;) {
|
||||
prev = text;
|
||||
text = text.replace(/<[^>]*>/g, "");
|
||||
}
|
||||
expect(text).toContain("Blood pressure · last 90 days · 142 readings");
|
||||
expect(text).toContain("Sleep · last 30 days · no readings");
|
||||
expect(text).toContain("Lab results · last 12 months");
|
||||
});
|
||||
|
||||
it("a reloaded list shows the same rows", () => {
|
||||
const live = render(<CoachTurnStepList steps={[BP, SLEEP]} active />);
|
||||
const stored = render(
|
||||
<CoachTurnStepList
|
||||
steps={JSON.parse(JSON.stringify([BP, SLEEP]))}
|
||||
active={false}
|
||||
/>,
|
||||
);
|
||||
const text = (h: string) => h.replace(/<svg[\s\S]*?<\/svg>/g, "");
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -55,6 +55,10 @@ function streaming(
|
||||
results: [],
|
||||
followUps: [],
|
||||
clarification: null,
|
||||
activity: [],
|
||||
memoryNote: null,
|
||||
planProposal: null,
|
||||
stop: null,
|
||||
inProgress: false,
|
||||
messageId: null,
|
||||
errorCode: null,
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.41 — what an answer assumed instead of asking, in one quiet line under
|
||||
* it: "Assumed: last 30 days. Change".
|
||||
*
|
||||
* Meta text, the value in the foreground. "Change" is a text button of the
|
||||
* same size, offered only under the latest answer and only when the server
|
||||
* sent alternatives for that assumption (`change_assumption` follow-ups).
|
||||
* A tap shows them as reply pills right below; a pill sends its label as
|
||||
* the person's message and the server answers from the alternative, without
|
||||
* asking again. Nothing opens by itself.
|
||||
*/
|
||||
import { useId, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { COACH_ASSUMPTION_KEYS } from "@/lib/ai/coach/dialog-keys";
|
||||
import type { CoachAssumption, CoachFollowUp } from "@/lib/ai/coach/types";
|
||||
|
||||
import { SuggestedReplies } from "./suggested-replies";
|
||||
import { COACH_FOCUS_RING } from "./focus-ring";
|
||||
|
||||
/** The alternatives the server offers for one assumption. */
|
||||
export function alternativesFor(
|
||||
assumption: CoachAssumption,
|
||||
followUps: readonly CoachFollowUp[],
|
||||
): CoachFollowUp[] {
|
||||
return followUps.filter(
|
||||
(chip) =>
|
||||
chip.kind === "change_assumption" &&
|
||||
chip.assumption?.kind === assumption.kind,
|
||||
);
|
||||
}
|
||||
|
||||
/** The catalog value in the reader's language, else the server's. */
|
||||
function optionLabel(
|
||||
t: (key: string) => string,
|
||||
option: CoachAssumption["value"],
|
||||
): string {
|
||||
const local = t(option.labelKey);
|
||||
return local === option.labelKey ? option.label : local;
|
||||
}
|
||||
|
||||
export interface CoachAssumptionLineProps {
|
||||
assumptions: CoachAssumption[];
|
||||
/** The latest answer's `change_assumption` chips; empty elsewhere. */
|
||||
changes: CoachFollowUp[];
|
||||
messageId: string | null;
|
||||
/** True while a turn is in flight. */
|
||||
disabled: boolean;
|
||||
onChange?: (followUp: CoachFollowUp, messageId: string) => void;
|
||||
}
|
||||
|
||||
export function CoachAssumptionLine({
|
||||
assumptions,
|
||||
changes,
|
||||
messageId,
|
||||
disabled,
|
||||
onChange,
|
||||
}: CoachAssumptionLineProps) {
|
||||
const { t } = useTranslations();
|
||||
const [openKind, setOpenKind] = useState<string | null>(null);
|
||||
const baseId = useId();
|
||||
if (assumptions.length === 0) return null;
|
||||
return (
|
||||
<div
|
||||
data-slot="coach-assumptions"
|
||||
className="flex w-full min-w-0 flex-col gap-2"
|
||||
>
|
||||
{assumptions.slice(0, 2).map((assumption) => {
|
||||
const alternatives =
|
||||
onChange && messageId && !disabled
|
||||
? alternativesFor(assumption, changes)
|
||||
: [];
|
||||
const open = openKind === assumption.kind && alternatives.length > 0;
|
||||
const panelId = `${baseId}-${assumption.kind}`;
|
||||
return (
|
||||
<div key={assumption.kind} className="flex flex-col gap-2">
|
||||
<p
|
||||
data-slot="coach-assumption-line"
|
||||
data-kind={assumption.kind}
|
||||
className="text-muted-foreground max-w-full text-xs leading-relaxed"
|
||||
>
|
||||
{t(COACH_ASSUMPTION_KEYS.line, {
|
||||
value: optionLabel(t, assumption.value),
|
||||
})}
|
||||
.{" "}
|
||||
{alternatives.length > 0 ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
data-slot="coach-assumption-change"
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? panelId : undefined}
|
||||
onClick={() => setOpenKind(open ? null : assumption.kind)}
|
||||
// Inline in the meta line; the hit area reaches past the text (a
|
||||
// 44 px target on phones) without making the line any taller.
|
||||
className={cn(
|
||||
"text-foreground relative h-auto px-1 py-0 text-xs font-normal underline underline-offset-4 after:absolute after:-inset-x-1 after:-inset-y-3.5 after:content-[''] sm:after:-inset-y-1.5",
|
||||
COACH_FOCUS_RING,
|
||||
)}
|
||||
>
|
||||
{t(COACH_ASSUMPTION_KEYS.change)}
|
||||
</Button>
|
||||
) : null}
|
||||
</p>
|
||||
{open && messageId && onChange ? (
|
||||
<div id={panelId}>
|
||||
{SuggestedReplies({
|
||||
slot: "coach-assumption-alternatives",
|
||||
messageId,
|
||||
disabled,
|
||||
max: 3,
|
||||
replies: alternatives.map((chip) => ({
|
||||
id: chip.id,
|
||||
label: chip.label,
|
||||
kind: "followUp",
|
||||
data: { "data-follow-up-id": chip.id },
|
||||
onSelect: () => onChange(chip, messageId),
|
||||
})),
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -208,7 +208,7 @@ export function AttachmentPicker({
|
||||
data-selected={isSelected ? "true" : undefined}
|
||||
className={cn(
|
||||
"border-border hover:bg-muted/50 flex w-full items-center gap-3 rounded-lg border p-3 text-left",
|
||||
"focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:outline-none",
|
||||
"focus-visible:ring-input-focus focus-visible:ring-[3px] focus-visible:outline-none",
|
||||
isSelected && "border-primary/40 bg-primary/5",
|
||||
disabled &&
|
||||
"cursor-not-allowed opacity-60 hover:bg-transparent",
|
||||
|
||||
Loaded 100 of 414 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user