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 | ||
|
|
4909a94485 | ||
|
|
4ec9b58415 | ||
|
|
3c59fc168e | ||
|
|
7533a3c5de | ||
|
|
b2c188bc46 | ||
|
|
11b6119482 | ||
|
|
c133b4ac74 | ||
|
|
5aa3f3346d | ||
|
|
b0ac267af9 | ||
|
|
0c3de5bdfb | ||
|
|
3bede8230b | ||
|
|
c49a04bac6 | ||
|
|
854cd31e18 | ||
|
|
9158cf3e24 | ||
|
|
22cb792379 | ||
|
|
abb1c9f8a9 | ||
|
|
2a3bd67b68 | ||
|
|
130ace74ee | ||
|
|
7400852fb4 | ||
|
|
25c34b0e7c | ||
|
|
7836610358 | ||
|
|
370fa10efa | ||
|
|
a6d841d3ae | ||
|
|
7d42cf7122 | ||
|
|
3381026acb | ||
|
|
f5b63d33ab | ||
|
|
524975d5f3 | ||
|
|
20085d5759 | ||
|
|
a9bddca998 | ||
|
|
7306bcda5c | ||
|
|
021c8c0adc | ||
|
|
adb0e7c3b1 | ||
|
|
25d486f956 | ||
|
|
2b5d22e93d |
No files matched your search
@@ -1,5 +1,239 @@
|
||||
# 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
|
||||
move behind an info icon; no view chip for a result that already toggles.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Coach panel.** Flush against the right edge, full height below the top
|
||||
bar, its own scroll area; the shell renders `/coach` without its container
|
||||
gutter. The toggle uses mirrored panel icons and moves with the panel.
|
||||
Its buttons and rows go compact only beside a mouse or trackpad; with
|
||||
touch they keep the 44 px target at any width, a tablet included.
|
||||
- **Answer row.** Copy, read aloud, retry, an info icon and the time in one
|
||||
left-aligned group; the info icon shows model and token count on hover,
|
||||
focus or tap, and Enter or Space keeps it open.
|
||||
- **Follow-ups.** No "show as chart/table" chip when the answer's result
|
||||
already offers that toggle; also for stored answers.
|
||||
|
||||
## [1.40.3] — 2026-10-05
|
||||
|
||||
Small fixes to the Today overview and the numbers behind it.
|
||||
|
||||
### Added
|
||||
|
||||
- **`signalLine` on the daily digest.** The muted line under the lead is now
|
||||
decided on the server: when the lead already talks about the top signal's
|
||||
metric, only the delta remains, and the line is null when nothing is left.
|
||||
Every client shows the same line.
|
||||
- **`basis` on health-status deviations.** `day` for the usual whole-day
|
||||
range, `sameHours` for the usual range at this time of day.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The Today lead is shown as whole sentences. A long sentence is no longer
|
||||
cut mid-word; the headline or the deterministic sentence takes its place,
|
||||
and only as a last resort is text shortened, at a word boundary.
|
||||
- Chart trend lines, legends and tooltips name every metric in the reader's
|
||||
language instead of showing its internal name (resting heart rate among
|
||||
them).
|
||||
- A personal usual range never collapses to a single value ("61–61 bpm"):
|
||||
it keeps at least the measurement's own day-to-day spread, so the next
|
||||
ordinary reading is not counted as outside it.
|
||||
- Glucose today is compared with the same hours of earlier days, up to the
|
||||
time of today's latest reading. A fasting morning no longer reads as below
|
||||
the range of whole days, and the range named is the usual one for this
|
||||
time of day.
|
||||
- Encrypting stored text in place or rotating the encryption key no longer
|
||||
makes old Coach conversations look recently active, and the Coach memory
|
||||
summary no longer moves a conversation either. Measurements, mood entries
|
||||
and every other encrypted record keep their sync position through the
|
||||
same kind of rewrite.
|
||||
- Conversations an earlier release made look recently active move back to
|
||||
their last message (migration 0375).
|
||||
|
||||
## [1.40.2] — 2026-10-05
|
||||
|
||||
A calmer Coach with conversations in a side panel and settings in place; the
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
<a href="https://testflight.apple.com/join/bucuTBpa">iOS TestFlight</a>
|
||||
</p>
|
||||
|
||||
<img width="2434" height="1247" alt="HealthLog dashboard: health metric tiles with 7-day trends, blood-pressure and weight charts, dark theme" src="docs/assets/dashboard-hero.jpeg" />
|
||||
<img width="2434" height="1247" alt="HealthLog dashboard in dark theme: the Today overview with a lead sentence, the health score ring, Today facts and worth-a-look cards, above metric tiles with 7-day trends" src="docs/assets/dashboard-hero.jpeg" />
|
||||
<p align="center">
|
||||
<img src="docs/assets/Insights.png"
|
||||
alt="Insights"
|
||||
|
||||
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 235 KiB After Width: | Height: | Size: 192 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 156 KiB After Width: | Height: | Size: 184 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 100 KiB |
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -12,14 +12,17 @@ import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
|
||||
*
|
||||
* Contracts under test:
|
||||
*
|
||||
* 1. Under an answer there is exactly one action row: the icon group and
|
||||
* the time on the left, the tokens and model on the right, both halves
|
||||
* on one line (their centres within 2 px, the row no taller than one
|
||||
* 44 px target), the left group starting on the bubble's left edge and
|
||||
* the meta ending inside the answer column.
|
||||
* 2. The follow-up chips sit in the answer's column: their left edge is
|
||||
* the bubble's, their text is the answer's size, and a chip is at most
|
||||
* 36 px tall beside a pointer and at least 44 px on a phone.
|
||||
* 1. Under an answer there is exactly one action row, left-aligned on the
|
||||
* bubble's left edge and no taller than one 44 px target: copy, read
|
||||
* aloud, try again, details, then the time. The model and the tokens
|
||||
* 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, 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
|
||||
@@ -246,35 +249,78 @@ test.describe("Coach answer layout", () => {
|
||||
const bubble = last.locator('[data-slot="coach-answer-bubble"]');
|
||||
const column = last.locator('[data-slot="coach-answer-column"]');
|
||||
const row = last.locator('[data-slot="coach-answer-actions"]');
|
||||
const left = row.locator('[data-slot="coach-answer-actions-left"]');
|
||||
const meta = row.locator('[data-slot="coach-answer-meta"]');
|
||||
const info = row.locator('[data-slot="coach-answer-info"]');
|
||||
const time = row.locator('[data-slot="coach-message-time"]');
|
||||
await expect(row).toHaveCount(1);
|
||||
await expect(meta).toContainText("1,234 tokens");
|
||||
await expect(row).toHaveCSS("opacity", "1");
|
||||
const [bubbleBox, columnBox, rowBox, leftBox, metaBox] = await Promise.all(
|
||||
[bubble, column, row, left, meta].map(box),
|
||||
);
|
||||
expect(
|
||||
Math.abs(
|
||||
leftBox.y + leftBox.height / 2 - (metaBox.y + metaBox.height / 2),
|
||||
),
|
||||
"left group and meta share one line",
|
||||
).toBeLessThanOrEqual(2);
|
||||
await expect(row).not.toContainText("tokens");
|
||||
await expect(row.locator('[data-slot="coach-answer-meta"]')).toHaveCount(0);
|
||||
await expect(info).toHaveAttribute("aria-label", "Answer details");
|
||||
const [bubbleBox, columnBox, rowBox, answerCopyBox, infoBox, timeBox] =
|
||||
await Promise.all(
|
||||
[
|
||||
bubble,
|
||||
column,
|
||||
row,
|
||||
row.locator('[data-slot="coach-copy-message"]'),
|
||||
info,
|
||||
time,
|
||||
].map(box),
|
||||
);
|
||||
expect(rowBox.height, "the row is one line").toBeLessThanOrEqual(44);
|
||||
expect(Math.abs(leftBox.x - bubbleBox.x)).toBeLessThanOrEqual(1);
|
||||
expect(metaBox.x + metaBox.width).toBeLessThanOrEqual(
|
||||
expect(Math.abs(answerCopyBox.x - bubbleBox.x)).toBeLessThanOrEqual(1);
|
||||
expect(infoBox.x, "details follows the other icons").toBeGreaterThan(
|
||||
answerCopyBox.x,
|
||||
);
|
||||
expect(timeBox.x, "the time comes last").toBeGreaterThan(infoBox.x);
|
||||
expect(timeBox.x + timeBox.width).toBeLessThanOrEqual(
|
||||
columnBox.x + columnBox.width + 1,
|
||||
);
|
||||
await expect(left.locator('[data-slot="coach-message-time"]')).toHaveCount(
|
||||
1,
|
||||
);
|
||||
for (const [b, name] of [
|
||||
[infoBox, "details"],
|
||||
[timeBox, "time"],
|
||||
] as const) {
|
||||
expect(
|
||||
Math.abs(
|
||||
b.y + b.height / 2 - (answerCopyBox.y + answerCopyBox.height / 2),
|
||||
),
|
||||
`${name} shares the row's line`,
|
||||
).toBeLessThanOrEqual(2);
|
||||
}
|
||||
|
||||
// The details tooltip: focus opens it on a desktop, a tap on a phone.
|
||||
const tip = page.locator('[data-slot="coach-answer-info-content"]');
|
||||
await expect(tip).toHaveCount(0);
|
||||
if (desktop) await info.focus();
|
||||
else await info.tap();
|
||||
await expect(tip).toBeVisible();
|
||||
await expect(tip).toContainText("Model: gpt-4o-mini-2024-07-18");
|
||||
await expect(tip).toContainText("1,234 tokens");
|
||||
await expect(info).toHaveAttribute("aria-describedby", /.+/);
|
||||
await shoot(page, testInfo, `answer-info-${viewport.label}`);
|
||||
if (desktop) {
|
||||
// Enter and Space on the focused icon keep it open; Escape closes it.
|
||||
for (const key of ["Enter", "Space"]) {
|
||||
await page.keyboard.press(key);
|
||||
await expect(tip).toBeVisible();
|
||||
}
|
||||
await page.keyboard.press("Escape");
|
||||
} else await info.tap();
|
||||
await expect(tip).toHaveCount(0);
|
||||
|
||||
// 2. The chips in the answer's column, at the answer's size.
|
||||
const chips = last.locator('[data-slot="coach-follow-up-chips"]');
|
||||
await expect(chips.locator("[data-follow-up-id]")).toHaveCount(2);
|
||||
const chipsBox = await box(chips);
|
||||
expect(Math.abs(chipsBox.x - bubbleBox.x)).toBeLessThanOrEqual(1);
|
||||
expect(chipsBox.y).toBeGreaterThanOrEqual(rowBox.y + rowBox.height - 1);
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
@@ -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"]'),
|
||||
|
||||
@@ -14,7 +14,13 @@ import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
|
||||
*
|
||||
* 1. From 1280 px the panel docks beside the thread, open on a first visit,
|
||||
* and the reading column (thread and composer) is exactly as wide with
|
||||
* the panel open as with it closed. The choice survives a reload.
|
||||
* the panel open as with it closed. The choice survives a reload. The
|
||||
* panel is flush with the viewport's right edge (no scrollbar gutter
|
||||
* beside it) and runs from the top of the window to the bottom; the
|
||||
* top bar ends at its left edge and the panel's header border is the
|
||||
* top bar's. The toggle is the bar's last item, within 16 px of its
|
||||
* right edge, open and shut; shut, no pixel of the panel shows and the
|
||||
* bar runs to the window's edge. New chat is 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
|
||||
@@ -26,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.
|
||||
*
|
||||
@@ -35,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 {
|
||||
@@ -230,6 +239,114 @@ async function expectNoSidewaysScroll(page: Page) {
|
||||
expect(overflow.main).toBeLessThanOrEqual(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(() => {
|
||||
const aside = document.querySelector(
|
||||
'[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,
|
||||
left: box.left,
|
||||
headerBottom: header.getBoundingClientRect().bottom,
|
||||
barBottom: barBox.bottom,
|
||||
barRight: barBox.right,
|
||||
gutter: main.offsetWidth - main.clientWidth,
|
||||
borders: [
|
||||
style.borderTopWidth,
|
||||
style.borderRightWidth,
|
||||
style.borderBottomWidth,
|
||||
style.borderLeftWidth,
|
||||
],
|
||||
};
|
||||
});
|
||||
expect(geometry.right).toBe(width);
|
||||
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) =>
|
||||
Math.round(
|
||||
(await panel(page)
|
||||
.locator(`[data-slot="${slot}"]`)
|
||||
.first()
|
||||
.boundingBox())!.height,
|
||||
);
|
||||
return {
|
||||
plans: await height("coach-panel-plans"),
|
||||
gear: await height("coach-settings"),
|
||||
row: await height("coach-history-select"),
|
||||
};
|
||||
}
|
||||
|
||||
async function shot(page: Page, testInfo: TestInfo, name: string) {
|
||||
const path = testInfo.outputPath(`${name}.png`);
|
||||
await page.screenshot({ path });
|
||||
@@ -292,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" });
|
||||
});
|
||||
@@ -318,6 +436,14 @@ test.describe("Coach page frame", () => {
|
||||
// The panel sits right of the column, never over it.
|
||||
const aside = (await panel(page).boundingBox())!;
|
||||
expect(aside.x).toBeGreaterThanOrEqual(open.threadX + open.thread);
|
||||
await expectPanelFlush(page, width, 900);
|
||||
// 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`);
|
||||
|
||||
@@ -326,11 +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, the panel is entirely off-screen, the top bar runs to the
|
||||
// window's edge and the toggle is still its last item.
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
document
|
||||
.querySelector('[data-slot="top-bar"]')!
|
||||
.getBoundingClientRect().right,
|
||||
),
|
||||
)
|
||||
.toBe(width);
|
||||
expect(
|
||||
await panel(page).evaluate((el) => {
|
||||
const box = el.getBoundingClientRect();
|
||||
return box.width === 0 || box.left >= window.innerWidth;
|
||||
}),
|
||||
"no pixel of the shut panel shows",
|
||||
).toBe(true);
|
||||
await expectToggleAtBarEnd(page);
|
||||
expect(Math.abs(closed.thread - open.thread)).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(closed.composer - open.composer)).toBeLessThanOrEqual(1);
|
||||
// Closed, nothing inside the panel is reachable.
|
||||
await expect(
|
||||
panel(page).locator('[data-slot="coach-history-search"]'),
|
||||
panel(page).locator('[data-slot="coach-history-select"]').first(),
|
||||
).not.toBeInViewport();
|
||||
await expectNoSidewaysScroll(page);
|
||||
await shot(page, testInfo, `coach-frame-${width}-closed`);
|
||||
@@ -342,13 +488,49 @@ 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();
|
||||
});
|
||||
}
|
||||
|
||||
test("1280 fine pointer: compact header buttons and rows", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await mockCoach(page);
|
||||
await openCoach(page);
|
||||
expect(await panelControlHeights(page)).toEqual({
|
||||
plans: 28,
|
||||
gear: 28,
|
||||
row: 36,
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("touch", () => {
|
||||
// A touch tablet in landscape docks the panel too; the controls keep the
|
||||
// 44 px floor because the size follows the input, not the width.
|
||||
test.use({ hasTouch: true });
|
||||
|
||||
test("1280 touch: header buttons and rows keep the 44 px floor", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await mockCoach(page);
|
||||
await openCoach(page);
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "open");
|
||||
expect(await panelControlHeights(page)).toEqual({
|
||||
plans: 44,
|
||||
gear: 44,
|
||||
row: 44,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("1440: picking a row opens it and New chat clears it, both in the URL", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -373,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(
|
||||
@@ -603,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");
|
||||
@@ -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,
|
||||
@@ -51,6 +62,7 @@ const BASE: DailyDigest = {
|
||||
steadyWeeks: 4,
|
||||
},
|
||||
topSignal: null,
|
||||
signalLine: null,
|
||||
briefingLead: null,
|
||||
lead: null,
|
||||
today: [],
|
||||
@@ -115,6 +127,12 @@ const STATES: Record<string, DailyDigest> = {
|
||||
text: "Your latest blood pressure is sitting in the optimal band.",
|
||||
source: "briefing",
|
||||
},
|
||||
// The lead already talks about blood pressure, so the server keeps only
|
||||
// the delta under it.
|
||||
signalLine: {
|
||||
headline: null,
|
||||
delta: "↓ ~10 mmHg systolic vs the start of the window",
|
||||
},
|
||||
today: FACTS,
|
||||
worthALook: [
|
||||
{
|
||||
@@ -151,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,
|
||||
@@ -188,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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,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);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -30,6 +30,8 @@ const NARRATIVE_DIGEST: DailyDigest = {
|
||||
composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"],
|
||||
},
|
||||
topSignal: LONG_HEADLINE_BRIEFING.signalsOfDay?.[0] ?? null,
|
||||
// The lead talks about blood pressure, so the server keeps only the delta.
|
||||
signalLine: { headline: null, delta: "−12 mmHg vs. 30-Tage-Mittel" },
|
||||
briefingLead: LONG_HEADLINE_BRIEFING.paragraph,
|
||||
lead: { text: LONG_HEADLINE_BRIEFING.paragraph, source: "briefing" },
|
||||
today: [],
|
||||
|
||||
@@ -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,18 +3019,28 @@
|
||||
"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.",
|
||||
"tokensUsed": "{count} Tokens",
|
||||
"tokensUsedWithModel": "{count} Tokens · {model}",
|
||||
"askAboutThis": "Den Coach dazu fragen",
|
||||
"readStrip": {
|
||||
"label": "Üblicher Bereich",
|
||||
"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}"
|
||||
},
|
||||
@@ -3141,10 +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",
|
||||
@@ -4492,6 +4570,7 @@
|
||||
"shiftUp": "{vital} hat sich gegenüber deiner jüngsten Norm nach oben verschoben.",
|
||||
"shiftDown": "{vital} hat sich gegenüber deiner jüngsten Norm nach unten verschoben.",
|
||||
"rangeContext": "Aktuell {value}, sonst {low}–{high}.",
|
||||
"rangeContextTimeOfDay": "Aktuell {value}, zu dieser Tageszeit sonst {low}–{high}.",
|
||||
"shiftContext": "Etwa {before} davor, {after} seitdem."
|
||||
},
|
||||
"breathingScreening": {
|
||||
@@ -5989,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.",
|
||||
@@ -6005,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",
|
||||
@@ -6121,7 +6200,8 @@
|
||||
"low": "Niedrig",
|
||||
"medium": "Mittel",
|
||||
"high": "Hoch"
|
||||
}
|
||||
},
|
||||
"coachNote": "Im Coach gilt die Denktiefe aus den Coach-Einstellungen."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -6660,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": {
|
||||
@@ -7155,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": {
|
||||
@@ -9778,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",
|
||||
@@ -9930,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",
|
||||
@@ -9943,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": {
|
||||
@@ -11018,6 +11114,7 @@
|
||||
"steadyWeeksOther": "Seit {count} Wochen stabil",
|
||||
"valueWithUnit": "{value} {unit}",
|
||||
"rangeWithUnit": "{low} bis {high} {unit}",
|
||||
"rangeAtThisTime": "{range} für diese Tageszeit",
|
||||
"vital": {
|
||||
"RESTING_HEART_RATE": "Ruhepuls",
|
||||
"HEART_RATE_VARIABILITY": "Herzfrequenzvariabilität",
|
||||
|
||||
@@ -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,18 +3019,28 @@
|
||||
"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.",
|
||||
"tokensUsed": "{count} tokens",
|
||||
"tokensUsedWithModel": "{count} tokens · {model}",
|
||||
"askAboutThis": "Ask the coach about this",
|
||||
"readStrip": {
|
||||
"label": "Usual range",
|
||||
"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}"
|
||||
},
|
||||
@@ -3141,10 +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",
|
||||
@@ -4492,6 +4570,7 @@
|
||||
"shiftUp": "{vital} has shifted up from your recent normal.",
|
||||
"shiftDown": "{vital} has shifted down from your recent normal.",
|
||||
"rangeContext": "Now {value}, usually {low}–{high}.",
|
||||
"rangeContextTimeOfDay": "Now {value}, usually {low}–{high} at this time of day.",
|
||||
"shiftContext": "Around {before} before, {after} since."
|
||||
},
|
||||
"breathingScreening": {
|
||||
@@ -5989,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.",
|
||||
@@ -6005,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",
|
||||
@@ -6121,7 +6200,8 @@
|
||||
"low": "Low",
|
||||
"medium": "Medium",
|
||||
"high": "High"
|
||||
}
|
||||
},
|
||||
"coachNote": "In the Coach, the thinking depth from the Coach settings applies."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -6660,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": {
|
||||
@@ -7155,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": {
|
||||
@@ -9778,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",
|
||||
@@ -9930,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",
|
||||
@@ -9943,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": {
|
||||
@@ -11018,6 +11114,7 @@
|
||||
"steadyWeeksOther": "Steady for {count} weeks",
|
||||
"valueWithUnit": "{value} {unit}",
|
||||
"rangeWithUnit": "{low} to {high} {unit}",
|
||||
"rangeAtThisTime": "{range} for this time of day",
|
||||
"vital": {
|
||||
"RESTING_HEART_RATE": "Resting heart rate",
|
||||
"HEART_RATE_VARIABILITY": "Heart-rate variability",
|
||||
|
||||
@@ -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,18 +3019,28 @@
|
||||
"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.",
|
||||
"tokensUsed": "{count} tokens",
|
||||
"tokensUsedWithModel": "{count} tokens · {model}",
|
||||
"askAboutThis": "Pregúntale al coach sobre esto",
|
||||
"readStrip": {
|
||||
"label": "Rango habitual",
|
||||
"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}"
|
||||
},
|
||||
@@ -3141,10 +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",
|
||||
@@ -4492,6 +4570,7 @@
|
||||
"shiftUp": "{vital} ha subido respecto a tu normal reciente.",
|
||||
"shiftDown": "{vital} ha bajado respecto a tu normal reciente.",
|
||||
"rangeContext": "Ahora {value}, normalmente {low}–{high}.",
|
||||
"rangeContextTimeOfDay": "Ahora {value}, normalmente {low}–{high} a esta hora del día.",
|
||||
"shiftContext": "Cerca de {before} antes, {after} desde entonces."
|
||||
},
|
||||
"breathingScreening": {
|
||||
@@ -5989,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.",
|
||||
@@ -6005,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",
|
||||
@@ -6121,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": {
|
||||
@@ -6660,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": {
|
||||
@@ -7155,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": {
|
||||
@@ -9778,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",
|
||||
@@ -9930,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",
|
||||
@@ -9943,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": {
|
||||
@@ -11018,6 +11114,7 @@
|
||||
"steadyWeeksOther": "Estable desde hace {count} semanas",
|
||||
"valueWithUnit": "{value} {unit}",
|
||||
"rangeWithUnit": "{low} a {high} {unit}",
|
||||
"rangeAtThisTime": "{range} para esta hora del día",
|
||||
"vital": {
|
||||
"RESTING_HEART_RATE": "Frecuencia cardíaca en reposo",
|
||||
"HEART_RATE_VARIABILITY": "Variabilidad de la frecuencia cardíaca",
|
||||
|
||||
@@ -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,18 +3019,28 @@
|
||||
"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é.",
|
||||
"tokensUsed": "{count} jetons",
|
||||
"tokensUsedWithModel": "{count} jetons · {model}",
|
||||
"askAboutThis": "Interroger le coach à ce sujet",
|
||||
"readStrip": {
|
||||
"label": "Plage habituelle",
|
||||
"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}"
|
||||
},
|
||||
@@ -3141,10 +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",
|
||||
@@ -4492,6 +4570,7 @@
|
||||
"shiftUp": "{vital} a augmenté par rapport à votre normale récente.",
|
||||
"shiftDown": "{vital} a diminué par rapport à votre normale récente.",
|
||||
"rangeContext": "Actuellement {value}, habituellement {low}–{high}.",
|
||||
"rangeContextTimeOfDay": "Actuellement {value}, habituellement {low}–{high} à ce moment de la journée.",
|
||||
"shiftContext": "Environ {before} avant, {after} depuis."
|
||||
},
|
||||
"breathingScreening": {
|
||||
@@ -5989,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é.",
|
||||
@@ -6005,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",
|
||||
@@ -6121,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": {
|
||||
@@ -6660,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": {
|
||||
@@ -7155,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": {
|
||||
@@ -9778,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é",
|
||||
@@ -9930,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",
|
||||
@@ -9943,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": {
|
||||
@@ -11018,6 +11114,7 @@
|
||||
"steadyWeeksOther": "Stable depuis {count} semaines",
|
||||
"valueWithUnit": "{value} {unit}",
|
||||
"rangeWithUnit": "{low} à {high} {unit}",
|
||||
"rangeAtThisTime": "{range} pour ce moment de la journée",
|
||||
"vital": {
|
||||
"RESTING_HEART_RATE": "Fréquence cardiaque au repos",
|
||||
"HEART_RATE_VARIABILITY": "Variabilité de la fréquence cardiaque",
|
||||
|
||||
@@ -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,18 +3019,28 @@
|
||||
"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.",
|
||||
"tokensUsed": "{count} token",
|
||||
"tokensUsedWithModel": "{count} token · {model}",
|
||||
"askAboutThis": "Chiedi al coach",
|
||||
"readStrip": {
|
||||
"label": "Intervallo abituale",
|
||||
"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}"
|
||||
},
|
||||
@@ -3141,10 +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",
|
||||
@@ -4492,6 +4570,7 @@
|
||||
"shiftUp": "{vital} si è spostato verso l'alto rispetto alla tua norma recente.",
|
||||
"shiftDown": "{vital} si è spostato verso il basso rispetto alla tua norma recente.",
|
||||
"rangeContext": "Ora {value}, di solito {low}–{high}.",
|
||||
"rangeContextTimeOfDay": "Ora {value}, di solito {low}–{high} a quest'ora del giorno.",
|
||||
"shiftContext": "Circa {before} prima, {after} da allora."
|
||||
},
|
||||
"breathingScreening": {
|
||||
@@ -5989,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.",
|
||||
@@ -6005,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",
|
||||
@@ -6121,7 +6200,8 @@
|
||||
"low": "Basso",
|
||||
"medium": "Medio",
|
||||
"high": "Alto"
|
||||
}
|
||||
},
|
||||
"coachNote": "Nel Coach vale la profondità di ragionamento delle impostazioni del Coach."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -6660,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": {
|
||||
@@ -7155,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": {
|
||||
@@ -9778,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",
|
||||
@@ -9930,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",
|
||||
@@ -9943,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": {
|
||||
@@ -11018,6 +11114,7 @@
|
||||
"steadyWeeksOther": "Stabile da {count} settimane",
|
||||
"valueWithUnit": "{value} {unit}",
|
||||
"rangeWithUnit": "da {low} a {high} {unit}",
|
||||
"rangeAtThisTime": "{range} per quest'ora del giorno",
|
||||
"vital": {
|
||||
"RESTING_HEART_RATE": "Frequenza cardiaca a riposo",
|
||||
"HEART_RATE_VARIABILITY": "Variabilità della frequenza cardiaca",
|
||||
|
||||
@@ -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,18 +3019,28 @@
|
||||
"hideConversations": "대화 숨기기",
|
||||
"heroGreeting": "내 데이터에 대해 무엇이든 물어보세요",
|
||||
"heroSubline": "추이, 약, 측정값에 대해 물어보세요.",
|
||||
"thinkingElapsed": "{seconds}초",
|
||||
"thinkingDuration": "{seconds}초 동안 생각했어요",
|
||||
"thinkingExpandAria": "코치가 참고한 내용 보기",
|
||||
"thinkingDetail": "건강 스냅샷을 바탕으로 답했어요.",
|
||||
"tokensUsed": "{count} 토큰",
|
||||
"tokensUsedWithModel": "{count} 토큰 · {model}",
|
||||
"askAboutThis": "코치에게 물어보기",
|
||||
"readStrip": {
|
||||
"label": "평소 범위",
|
||||
"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}"
|
||||
},
|
||||
@@ -3141,10 +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}분 전",
|
||||
@@ -4492,6 +4570,7 @@
|
||||
"shiftUp": "{vital} — 최근 평소 수준보다 올라갔어요.",
|
||||
"shiftDown": "{vital} — 최근 평소 수준보다 내려갔어요.",
|
||||
"rangeContext": "지금 {value}, 평소 {low}–{high}.",
|
||||
"rangeContextTimeOfDay": "지금 {value}, 이 시간대 평소 {low}–{high}.",
|
||||
"shiftContext": "이전엔 약 {before}, 이후엔 {after}."
|
||||
},
|
||||
"breathingScreening": {
|
||||
@@ -5989,7 +6068,6 @@
|
||||
"categoryGoal": "목표",
|
||||
"categoryConstraint": "제약",
|
||||
"categoryContext": "맥락",
|
||||
"forget": "잊기",
|
||||
"forgetAria": "이 정보 잊기",
|
||||
"forgetTitle": "이 정보를 잊을까요?",
|
||||
"forgetBody": "다음 메시지부터 코치가 이 정보를 쓰지 않아요. 한번 잊은 정보는 되돌릴 수 없어요.",
|
||||
@@ -6005,7 +6083,8 @@
|
||||
"forgotError": "그 정보를 잊지 못했어요. 다시 시도해 주세요.",
|
||||
"clearedToast": "저장된 정보 {count}개를 잊었어요.",
|
||||
"clearError": "저장된 정보를 지우지 못했어요. 다시 시도해 주세요.",
|
||||
"detail": "코치는 이 정보로 답변을 상황에 맞게 유지해요. 잊었으면 하는 건 지우세요."
|
||||
"detail": "코치는 이 정보로 답변을 상황에 맞게 유지해요. 잊었으면 하는 건 지우세요.",
|
||||
"categoryMedication": "약"
|
||||
},
|
||||
"coachReminders": {
|
||||
"title": "내 알림",
|
||||
@@ -6121,7 +6200,8 @@
|
||||
"low": "낮음",
|
||||
"medium": "보통",
|
||||
"high": "높음"
|
||||
}
|
||||
},
|
||||
"coachNote": "코치에서는 코치 설정의 사고 깊이가 적용됩니다."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -6660,6 +6740,13 @@
|
||||
"savedOn": "코치를 켰어요.",
|
||||
"savedOff": "코치를 숨겼어요.",
|
||||
"saveError": "변경 사항을 저장하지 못했어요. 다시 시도해 주세요."
|
||||
},
|
||||
"memory": {
|
||||
"edit": "편집",
|
||||
"source": {
|
||||
"user": "내가 알려 줌",
|
||||
"coach": "코치가 기억함"
|
||||
}
|
||||
}
|
||||
},
|
||||
"integrationRedirectGuide": {
|
||||
@@ -7134,6 +7221,11 @@
|
||||
"documentAi": {
|
||||
"title": "문서 읽기",
|
||||
"description": "저장된 문서의 AI 읽기, 검사 결과지 스캔, 텍스트에서 약물 추출. 서버 밖으로 나가는 데이터의 대부분을 차지합니다."
|
||||
},
|
||||
"reasoning": {
|
||||
"enabled": "깊은 생각 허용",
|
||||
"max": "최대 생각 깊이",
|
||||
"description": "모델이 답하기 전에 생각합니다: 코치, 일일 브리핑, 기간 요약. 더 정확하지만 토큰을 더 씁니다."
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
@@ -9778,9 +9870,6 @@
|
||||
"showResult": "이전 표 여는 중",
|
||||
"reuse": "앞서 만든 표 사용 중",
|
||||
"headerActive": "{label}…",
|
||||
"headerDoneOne": "출처 1개 확인함",
|
||||
"headerDoneFew": "출처 {count}개 확인함",
|
||||
"headerDoneOther": "출처 {count}개 확인함",
|
||||
"listLabel": "코치가 확인한 내용",
|
||||
"toggleShow": "코치가 확인한 내용 보기",
|
||||
"toggleHide": "코치가 확인한 내용 숨기기",
|
||||
@@ -9930,7 +10019,8 @@
|
||||
"relatedMetric": "함께 보기: {metric}",
|
||||
"continue": "계속 찾아보기",
|
||||
"settingLabel": "후속 질문 추천",
|
||||
"settingHint": "코치의 가장 최근 답변 아래에 후속 질문을 최대 세 개까지 보여줘요."
|
||||
"settingHint": "코치의 가장 최근 답변 아래에 후속 질문을 최대 세 개까지 보여줘요.",
|
||||
"changeAssumption": "대신: {value}"
|
||||
},
|
||||
"clarify": {
|
||||
"cardLabel": "코치가 질문이 있어요",
|
||||
@@ -9943,6 +10033,12 @@
|
||||
"last90days": "최근 90일",
|
||||
"lastYear": "최근 12개월",
|
||||
"allTime": "전체 기간"
|
||||
},
|
||||
"comparison": {
|
||||
"baseline90d": "평소 90일과 비교하기"
|
||||
},
|
||||
"anchor": {
|
||||
"illness": "{date} 질환 이후부터"
|
||||
}
|
||||
},
|
||||
"reuse": {
|
||||
@@ -11018,6 +11114,7 @@
|
||||
"steadyWeeksOther": "{count}주째 안정적",
|
||||
"valueWithUnit": "{value} {unit}",
|
||||
"rangeWithUnit": "{low}~{high} {unit}",
|
||||
"rangeAtThisTime": "이 시간대 기준 {range}",
|
||||
"vital": {
|
||||
"RESTING_HEART_RATE": "안정 시 심박수",
|
||||
"HEART_RATE_VARIABILITY": "심박 변이도",
|
||||
|
||||
@@ -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,18 +3019,28 @@
|
||||
"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.",
|
||||
"tokensUsed": "{count} tokenów",
|
||||
"tokensUsedWithModel": "{count} tokenów · {model}",
|
||||
"askAboutThis": "Zapytaj trenera o to",
|
||||
"readStrip": {
|
||||
"label": "Typowy zakres",
|
||||
"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}"
|
||||
},
|
||||
@@ -3141,10 +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",
|
||||
@@ -4492,6 +4570,7 @@
|
||||
"shiftUp": "{vital} przesunął się w górę względem Twojej ostatniej normy.",
|
||||
"shiftDown": "{vital} przesunął się w dół względem Twojej ostatniej normy.",
|
||||
"rangeContext": "Teraz {value}, zwykle {low}–{high}.",
|
||||
"rangeContextTimeOfDay": "Teraz {value}, zwykle {low}–{high} o tej porze dnia.",
|
||||
"shiftContext": "Około {before} wcześniej, {after} od tego czasu."
|
||||
},
|
||||
"breathingScreening": {
|
||||
@@ -5989,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ć.",
|
||||
@@ -6005,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",
|
||||
@@ -6121,7 +6200,8 @@
|
||||
"low": "Niskie",
|
||||
"medium": "Średnie",
|
||||
"high": "Wysokie"
|
||||
}
|
||||
},
|
||||
"coachNote": "W Coachu obowiązuje głębokość myślenia z ustawień Coacha."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -6660,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": {
|
||||
@@ -7155,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": {
|
||||
@@ -9778,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",
|
||||
@@ -9930,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",
|
||||
@@ -9943,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": {
|
||||
@@ -11018,6 +11114,7 @@
|
||||
"steadyWeeksOther": "Stabilnie od {count} tygodni",
|
||||
"valueWithUnit": "{value} {unit}",
|
||||
"rangeWithUnit": "{low} do {high} {unit}",
|
||||
"rangeAtThisTime": "{range} o tej porze dnia",
|
||||
"vital": {
|
||||
"RESTING_HEART_RATE": "Tętno spoczynkowe",
|
||||
"HEART_RATE_VARIABILITY": "Zmienność rytmu serca",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.40.2",
|
||||
"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",
|
||||
|
||||
@@ -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,26 @@
|
||||
-- Move a Coach conversation's updated_at back to its newest message.
|
||||
--
|
||||
-- The free-text encryption backfill (v1.39.3) and the memory-summary refresh
|
||||
-- rewrote conversation rows through a Prisma update, which stamped updated_at
|
||||
-- with the run time. The Coach panel orders and groups by that column, so
|
||||
-- conversations nobody had touched in months showed up under "Today".
|
||||
--
|
||||
-- A turn stamps updated_at in the same transaction that creates its message,
|
||||
-- so the newest message's created_at is the conversation's real last
|
||||
-- activity. Only rows more than a minute later than that move back; a rename
|
||||
-- or a document attached shortly after the last turn keeps its stamp, and a
|
||||
-- conversation without messages is left alone. Running it again changes
|
||||
-- nothing: a repaired row is no longer later than its newest message.
|
||||
--
|
||||
-- One set-based statement. The per-conversation maximum is read from
|
||||
-- "coach_messages_conversation_id_created_at_idx" (conversation_id,
|
||||
-- created_at), so it does not sort the message table.
|
||||
UPDATE "coach_conversations" AS c
|
||||
SET "updated_at" = m."last_at"
|
||||
FROM (
|
||||
SELECT "conversation_id", MAX("created_at") AS "last_at"
|
||||
FROM "coach_messages"
|
||||
GROUP BY "conversation_id"
|
||||
) AS m
|
||||
WHERE m."conversation_id" = c."id"
|
||||
AND c."updated_at" > m."last_at" + INTERVAL '1 minute';
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.2";
|
||||
/* @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/*`
|
||||
|
||||
@@ -52,6 +52,7 @@ interface RowSnapshot {
|
||||
id: string;
|
||||
tags: string | null;
|
||||
note: string | null;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
@@ -74,7 +75,7 @@ async function main(): Promise<void> {
|
||||
tags: { not: null },
|
||||
note: null,
|
||||
},
|
||||
select: { id: true, tags: true, note: true },
|
||||
select: { id: true, tags: true, note: true, updatedAt: true },
|
||||
});
|
||||
|
||||
console.log(`[backfill-note] scanned ${candidates.length} candidate rows`);
|
||||
@@ -87,9 +88,11 @@ async function main(): Promise<void> {
|
||||
|
||||
updated += 1;
|
||||
if (!dryRun) {
|
||||
// The entry did not change for its reader; it keeps its `updatedAt`
|
||||
// (the sync cursor) rather than taking the script's run time.
|
||||
await prisma.moodEntry.update({
|
||||
where: { id: row.id },
|
||||
data: { note, tags: newTags },
|
||||
data: { note, tags: newTags, updatedAt: row.updatedAt },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,8 @@ import {
|
||||
import {
|
||||
ENCRYPTED_COLUMNS,
|
||||
encryptedColumnKey,
|
||||
preserveUpdatedAt,
|
||||
UPDATED_AT_MODELS,
|
||||
type EncryptedColumn,
|
||||
} from "@/lib/crypto/encrypted-columns";
|
||||
import { tokeniseAndHash } from "@/lib/documents/content-index";
|
||||
@@ -106,6 +108,15 @@ async function rotateRegistryColumn(
|
||||
};
|
||||
}
|
||||
|
||||
/** True for a Prisma "record to update not found" (P2025). */
|
||||
function isRowGone(err: unknown): boolean {
|
||||
return (
|
||||
typeof err === "object" &&
|
||||
err !== null &&
|
||||
(err as { code?: unknown }).code === "P2025"
|
||||
);
|
||||
}
|
||||
|
||||
function shouldRotate(value: string | null): boolean {
|
||||
if (!value) return false;
|
||||
const id = extractKeyId(value);
|
||||
@@ -122,8 +133,8 @@ interface PagedDelegate<V> {
|
||||
where?: { id: { gt: string } };
|
||||
}) => Promise<Array<Record<string, unknown>>>;
|
||||
update: (args: {
|
||||
where: { id: string };
|
||||
data: Record<string, V>;
|
||||
where: { id: string; updatedAt?: Date };
|
||||
data: Record<string, V | Date>;
|
||||
}) => Promise<unknown>;
|
||||
}
|
||||
|
||||
@@ -139,11 +150,16 @@ const PAGE_SIZE = 5_000;
|
||||
async function* pagedRows(
|
||||
delegate: PagedDelegate<unknown>,
|
||||
field: string,
|
||||
table: string,
|
||||
): AsyncGenerator<Record<string, unknown>> {
|
||||
let cursor: string | null = null;
|
||||
for (;;) {
|
||||
const rows = await delegate.findMany({
|
||||
select: { id: true, [field]: true },
|
||||
select: {
|
||||
id: true,
|
||||
[field]: true,
|
||||
...(UPDATED_AT_MODELS.has(table) ? { updatedAt: true } : {}),
|
||||
},
|
||||
orderBy: { id: "asc" },
|
||||
take: PAGE_SIZE,
|
||||
// `id > last`, not Prisma's `cursor` + `skip`: the cursor form looks
|
||||
@@ -178,6 +194,7 @@ async function rotateStringColumn(
|
||||
for await (const row of pagedRows(
|
||||
delegate as PagedDelegate<unknown>,
|
||||
field,
|
||||
table,
|
||||
)) {
|
||||
const v = row[field] as string | null;
|
||||
// `scanned` counts rows that hold ciphertext, as the in-app rotation does.
|
||||
@@ -187,9 +204,16 @@ async function rotateStringColumn(
|
||||
const id = row.id as string;
|
||||
try {
|
||||
const re = encrypt(decrypt(v));
|
||||
await delegate.update({ where: { id }, data: { [field]: re } });
|
||||
// Re-sealing is not an edit: the row keeps its own updatedAt.
|
||||
const keepUpdatedAt = preserveUpdatedAt(table, row.updatedAt);
|
||||
await delegate.update({
|
||||
where: { id, ...keepUpdatedAt },
|
||||
data: { [field]: re, ...keepUpdatedAt },
|
||||
});
|
||||
result.rotated++;
|
||||
} catch (err) {
|
||||
// Edited or deleted since the read: the next run picks it up.
|
||||
if (isRowGone(err)) continue;
|
||||
result.errors++;
|
||||
console.error(`[${table}.${field}] row ${id}: ${(err as Error).message}`);
|
||||
}
|
||||
@@ -218,6 +242,7 @@ async function rotateBytesColumn(
|
||||
for await (const row of pagedRows(
|
||||
delegate as PagedDelegate<unknown>,
|
||||
field,
|
||||
table,
|
||||
)) {
|
||||
const buf = row[field] as Uint8Array | null;
|
||||
if (!buf || buf.byteLength === 0) continue;
|
||||
@@ -230,9 +255,14 @@ async function rotateBytesColumn(
|
||||
const encoded = Buffer.from(rotated, "utf8");
|
||||
const next = new Uint8Array(new ArrayBuffer(encoded.byteLength));
|
||||
next.set(encoded);
|
||||
await delegate.update({ where: { id }, data: { [field]: next } });
|
||||
const keepUpdatedAt = preserveUpdatedAt(table, row.updatedAt);
|
||||
await delegate.update({
|
||||
where: { id, ...keepUpdatedAt },
|
||||
data: { [field]: next, ...keepUpdatedAt },
|
||||
});
|
||||
result.rotated++;
|
||||
} catch (err) {
|
||||
if (isRowGone(err)) continue;
|
||||
result.errors++;
|
||||
console.error(`[${table}.${field}] row ${id}: ${(err as Error).message}`);
|
||||
}
|
||||
@@ -432,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",
|
||||
@@ -450,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",
|
||||
@@ -898,7 +937,12 @@ async function main() {
|
||||
};
|
||||
for (;;) {
|
||||
const rows = await prisma.documentContentIndex.findMany({
|
||||
select: { id: true, textEncrypted: true, verbatimTextEncrypted: true },
|
||||
select: {
|
||||
id: true,
|
||||
textEncrypted: true,
|
||||
verbatimTextEncrypted: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
orderBy: { id: "asc" },
|
||||
take: 100,
|
||||
...(cursor ? { where: { id: { gt: cursor } } } : {}),
|
||||
@@ -924,8 +968,10 @@ async function main() {
|
||||
? reEncryptBytes(Buffer.from(verbatimBuf!).toString("utf8"))
|
||||
: undefined;
|
||||
await prisma.documentContentIndex.update({
|
||||
where: { id: row.id },
|
||||
// Re-sealing is not an edit: the row keeps its own updatedAt.
|
||||
where: { id: row.id, updatedAt: row.updatedAt },
|
||||
data: {
|
||||
updatedAt: row.updatedAt,
|
||||
textEncrypted: nextBytes,
|
||||
searchTokens,
|
||||
...(verbatimNext ? { verbatimTextEncrypted: verbatimNext } : {}),
|
||||
|
||||
@@ -32,12 +32,18 @@
|
||||
* - Two lab panels of biomarkers across two dates (quantitative with
|
||||
* reference ranges + qualitative "negativ" rows)
|
||||
* - Two illness episodes — a resolved acute cold with a day-by-day symptom
|
||||
* curve, and an active chronic condition carrying a recent flare
|
||||
* curve, and a recurring condition with a resolved spring flare; nothing
|
||||
* is left open, so the account is not in Rest Mode
|
||||
* - Cycle tracking opted in: ~5 observed cycles with biphasic basal body
|
||||
* temperature, period flow, fertile-window mucus/OPK, per-day symptoms,
|
||||
* and a cached forward prediction
|
||||
* - An AI-configured Coach with one short sample conversation
|
||||
* - App settings (registration disabled, English locale)
|
||||
* - Two practitioners, a knee arthroscopy with its operative report and
|
||||
* discharge letter linked (small generated PDFs), its follow-up visit, and
|
||||
* a check-up booked for tomorrow morning
|
||||
* - An AI-configured Coach with five sample conversations spread from
|
||||
* today back over a few weeks
|
||||
* - App settings (registration disabled, English locale, the key-backup
|
||||
* step confirmed for the key the demo runs with)
|
||||
*
|
||||
* Every date is relative to "now" so the demo stays fresh on every re-seed.
|
||||
* What ages is the SEEDED DATA, not the script: the pillar windows keep
|
||||
@@ -54,15 +60,21 @@
|
||||
* score can show.
|
||||
*
|
||||
* Usage: npx tsx scripts/seed-demo.ts
|
||||
* Requires DATABASE_URL env var. The Coach sample conversation additionally
|
||||
* Requires DATABASE_URL env var. The Coach sample conversations additionally
|
||||
* needs ENCRYPTION_KEYS / ENCRYPTION_KEY (the same key the app runs with).
|
||||
*/
|
||||
|
||||
import { Buffer } from "node:buffer";
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import pg from "pg";
|
||||
import { encryptToBytes } from "../src/lib/ai/coach/bytes-codec";
|
||||
import { encrypt } from "../src/lib/crypto";
|
||||
import {
|
||||
encrypt,
|
||||
encryptBytes,
|
||||
getActiveKeyId,
|
||||
getKeyFingerprint,
|
||||
} from "../src/lib/crypto";
|
||||
import {
|
||||
DEFAULT_DASHBOARD_LAYOUT,
|
||||
serializeDashboardLayout,
|
||||
@@ -80,6 +92,15 @@ if (!DATABASE_URL) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Every timestamp column here is `timestamp without time zone` holding UTC,
|
||||
// which is how Prisma reads it. node-postgres serialises a Date in the host's
|
||||
// local zone by default and Postgres drops the offset on the cast, so a seed
|
||||
// run from a machine on Berlin time stored every instant two hours in the
|
||||
// future (today's Coach thread stamped after "now", the CGM stream running
|
||||
// into the evening). Sending Dates as UTC makes the result independent of
|
||||
// the seed host's clock zone.
|
||||
pg.defaults.parseInputDatesAsUTC = true;
|
||||
|
||||
const pool = new pg.Pool({ connectionString: DATABASE_URL });
|
||||
|
||||
// ── Helpers ──────────────────────────────────────
|
||||
@@ -198,6 +219,55 @@ function randomWalk(
|
||||
return values;
|
||||
}
|
||||
|
||||
// A one-page PDF carrying a few lines of Helvetica text, built by hand so the
|
||||
// seed needs no PDF library. The byte offsets in the cross-reference table are
|
||||
// computed, so viewers and the thumbnail renderer open it as a normal file.
|
||||
function simplePdf(lines: string[]): Buffer {
|
||||
const esc = (t: string) => t.replace(/[\\()]/g, (c) => `\\${c}`);
|
||||
const text = lines
|
||||
.map((line, i) =>
|
||||
i === 0
|
||||
? `BT /F1 16 Tf 56 780 Td (${esc(line)}) Tj ET`
|
||||
: `BT /F1 11 Tf 56 ${760 - i * 18} Td (${esc(line)}) Tj ET`,
|
||||
)
|
||||
.join("\n");
|
||||
const objects = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>",
|
||||
`<< /Length ${Buffer.byteLength(text, "latin1")} >>\nstream\n${text}\nendstream`,
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
||||
];
|
||||
let body = "%PDF-1.4\n";
|
||||
const offsets: number[] = [];
|
||||
objects.forEach((obj, i) => {
|
||||
offsets.push(Buffer.byteLength(body, "latin1"));
|
||||
body += `${i + 1} 0 obj\n${obj}\nendobj\n`;
|
||||
});
|
||||
const xrefAt = Buffer.byteLength(body, "latin1");
|
||||
body += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
|
||||
for (const off of offsets)
|
||||
body += `${String(off).padStart(10, "0")} 00000 n \n`;
|
||||
body += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefAt}\n%%EOF\n`;
|
||||
return Buffer.from(body, "latin1");
|
||||
}
|
||||
|
||||
// Pin a series' last point (today) to the median of the four weeks before it.
|
||||
// A free walk can end on a tail value, and the vitals check holds today's
|
||||
// reading against exactly that personal band: a demo whose oxygen saturation
|
||||
// happened to land low read "below your range" on the start page. The median
|
||||
// sits inside the band by construction, so today reads as an ordinary day.
|
||||
function settleLatest(values: number[], lookback = 28): void {
|
||||
if (values.length < 2) return;
|
||||
const prior = values
|
||||
.slice(Math.max(0, values.length - 1 - lookback), values.length - 1)
|
||||
.sort((a, b) => a - b);
|
||||
const mid = Math.floor(prior.length / 2);
|
||||
const median =
|
||||
prior.length % 2 === 1 ? prior[mid] : (prior[mid - 1] + prior[mid]) / 2;
|
||||
values[values.length - 1] = Math.round(median * 10) / 10;
|
||||
}
|
||||
|
||||
// ── Main ─────────────────────────────────────────
|
||||
|
||||
async function seed() {
|
||||
@@ -294,7 +364,6 @@ async function seed() {
|
||||
]);
|
||||
const demoDashboardLayout = serializeDashboardLayout({
|
||||
...DEFAULT_DASHBOARD_LAYOUT,
|
||||
heroVisible: true,
|
||||
widgets: DEFAULT_DASHBOARD_LAYOUT.widgets.map((w) =>
|
||||
SHOWCASE_TILE_IDS.has(w.id) ? { ...w, tileVisible: true } : w,
|
||||
),
|
||||
@@ -342,16 +411,22 @@ async function seed() {
|
||||
);
|
||||
// Body fat: 24% → trending to a healthy ~19%
|
||||
const bodyFat = randomWalk(24.0, 19.0, span, 0.4, VALUE_RANGES.BODY_FAT);
|
||||
// Sleep duration in MINUTES (the SLEEP_DURATION unit): ~7h → ~7h45m
|
||||
// Sleep duration in MINUTES (the SLEEP_DURATION unit): ~7h → ~7h40m.
|
||||
// Kept close to the ~7.5 h per-stage nights below, which the start page
|
||||
// holds against this trailing average: a looser walk drifted to almost
|
||||
// nine hours and last night read as "1h 4m less than usual".
|
||||
const sleepMin = randomWalk(
|
||||
420,
|
||||
465,
|
||||
460,
|
||||
span,
|
||||
25,
|
||||
12,
|
||||
VALUE_RANGES.SLEEP_DURATION,
|
||||
);
|
||||
// Steps: 6500 → a solid ~9000
|
||||
const steps = randomWalk(6500, 9000, span, 1500);
|
||||
for (const series of [weights, sysBP, diaBP, pulse, restingHr]) {
|
||||
settleLatest(series);
|
||||
}
|
||||
|
||||
for (let i = 0; i < span; i++) {
|
||||
const date = daysAgo(days - i);
|
||||
@@ -603,7 +678,12 @@ async function seed() {
|
||||
{ context: "POSTPRANDIAL", hour: 19, base: 118, jitter: 12 },
|
||||
{ context: "BEDTIME", hour: 22, base: 99, jitter: 6 },
|
||||
];
|
||||
for (let i = 0; i < span; i++) {
|
||||
// Glucose stops at yesterday. The vitals check compares a day's mean
|
||||
// against the band of full days, so a part day seeded up to the current
|
||||
// hour (fasting and breakfast only) read "below your range", and seeding
|
||||
// the whole day put the afternoon in the future. Yesterday's full day is
|
||||
// what the start page then reads.
|
||||
for (let i = 0; i < span - 1; i++) {
|
||||
for (const r of glucoseReadings) {
|
||||
const at = daysAgoAt(days - i, r.hour, Math.floor(Math.random() * 30));
|
||||
const value = Math.round(r.base + (Math.random() - 0.5) * r.jitter);
|
||||
@@ -632,7 +712,9 @@ async function seed() {
|
||||
console.log("Creating continuous glucose (CGM) stream...");
|
||||
const cgmDays = 14;
|
||||
const cgmStepMin = 15;
|
||||
for (let d = cgmDays - 1; d >= 0; d--) {
|
||||
// The stream ends with yesterday, for the reason given above the spot
|
||||
// readings.
|
||||
for (let d = cgmDays; d >= 1; d--) {
|
||||
for (let minute = 0; minute < 24 * 60; minute += cgmStepMin) {
|
||||
const hour = minute / 60;
|
||||
// Baseline overnight ~90; dawn phenomenon lifts it slightly toward
|
||||
@@ -704,6 +786,9 @@ async function seed() {
|
||||
);
|
||||
// Flights climbed: ~8–14/day.
|
||||
const flights = randomWalk(9, 13, span, 4, VALUE_RANGES.FLIGHTS_CLIMBED);
|
||||
for (const series of [hrvSdnn, hrvRmssd, spo2, respRate]) {
|
||||
settleLatest(series);
|
||||
}
|
||||
|
||||
for (let i = 0; i < span; i++) {
|
||||
const date = daysAgo(days - i);
|
||||
@@ -1135,6 +1220,11 @@ async function seed() {
|
||||
const tags = tagOptions[5 - rawScore];
|
||||
const loggedAt = new Date(date);
|
||||
loggedAt.setHours(21, Math.floor(Math.random() * 59), 0);
|
||||
// Today's evening entry has not happened yet when the seed runs
|
||||
// earlier in the day; it is logged a few minutes ago instead.
|
||||
if (loggedAt.getTime() > Date.now()) {
|
||||
loggedAt.setTime(Date.now() - 5 * 60_000);
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO mood_entries (id, user_id, date, mood, score, tags, source, mood_logged_at, synced_at, created_at, updated_at)
|
||||
@@ -1289,14 +1379,17 @@ async function seed() {
|
||||
// ── Illness / condition journal ───────────
|
||||
// Two episodes so the journal + per-condition timeline both render with
|
||||
// signal: (1) a short, fully-resolved cold ~7 weeks ago with a day-by-day
|
||||
// symptom curve, and (2) an active chronic condition (seasonal allergic
|
||||
// rhinitis) carrying a recent FLARE that hangs off it. Each episode gets
|
||||
// symptom curve, and (2) a recurring condition (seasonal allergic
|
||||
// rhinitis) with a spring FLARE that hangs off it. Each episode gets
|
||||
// IllnessDayLog rows (functional impact + optional fever + an encrypted
|
||||
// note) and per-day symptom links from the seeded catalogue, so the
|
||||
// retrospective curve + symptom chips have content. Day-log + episode
|
||||
// notes are AES-256-GCM Bytes (the `*Encrypted` convention), written with
|
||||
// the same codec the app uses — never plaintext.
|
||||
console.log("Creating illness / condition journal...");
|
||||
// The pollen flare starts about 24 weeks back, in the spring of the
|
||||
// year the seed runs in, and every day of it is placed from this anchor.
|
||||
const FLARE_ONSET_DAYS_AGO = 170;
|
||||
|
||||
// Resolve a catalogue symptom id by its stable machine key.
|
||||
const illnessSymptomId = async (key: string): Promise<string> => {
|
||||
@@ -1430,27 +1523,35 @@ async function seed() {
|
||||
});
|
||||
}
|
||||
|
||||
// (2) Active chronic condition + a recent flare hanging off it.
|
||||
// (2) A recurring condition with a flare hanging off it, both closed.
|
||||
// Any episode left open puts the account in Rest Mode (an ongoing chronic
|
||||
// condition deliberately included), and an open one dated 400 days back
|
||||
// greeted every demo visitor with "Rest mode, day 401" and a score marked
|
||||
// as not penalised. The season and its flare therefore sit in the spring
|
||||
// and are resolved; the illness pages still show the whole history.
|
||||
const allergyId = cuid();
|
||||
await client.query(
|
||||
`INSERT INTO illness_episodes (id, user_id, label, type, lifecycle, onset_at, resolved_at, note_encrypted, created_at, updated_at)
|
||||
VALUES ($1, $2, 'Seasonal allergic rhinitis', 'ALLERGY', 'CHRONIC_ONGOING', $3, NULL, $4, NOW(), NOW())`,
|
||||
VALUES ($1, $2, 'Seasonal allergic rhinitis', 'ALLERGY', 'RECURRING', $3, $4, $5, NOW(), NOW())`,
|
||||
[
|
||||
allergyId,
|
||||
userId,
|
||||
daysAgo(400),
|
||||
daysAgo(FLARE_ONSET_DAYS_AGO - 14),
|
||||
encBytes("Tree pollen each spring. Antihistamine helps."),
|
||||
],
|
||||
);
|
||||
// The flare: a still-open bout this past week referencing the parent.
|
||||
// The flare: a bout at the height of the pollen season, referencing the
|
||||
// parent, resolved a week after it started.
|
||||
const flareId = cuid();
|
||||
await client.query(
|
||||
`INSERT INTO illness_episodes (id, user_id, label, type, lifecycle, onset_at, resolved_at, parent_condition_id, note_encrypted, created_at, updated_at)
|
||||
VALUES ($1, $2, 'Pollen flare', 'ALLERGY', 'FLARE', $3, NULL, $4, $5, NOW(), NOW())`,
|
||||
VALUES ($1, $2, 'Pollen flare', 'ALLERGY', 'FLARE', $3, $4, $5, $6, NOW(), NOW())`,
|
||||
[
|
||||
flareId,
|
||||
userId,
|
||||
daysAgo(5),
|
||||
daysAgo(FLARE_ONSET_DAYS_AGO),
|
||||
daysAgo(FLARE_ONSET_DAYS_AGO - 7),
|
||||
allergyId,
|
||||
encBytes("High pollen count this week — symptoms back."),
|
||||
],
|
||||
@@ -1462,7 +1563,7 @@ async function seed() {
|
||||
symptoms: Array<{ key: string; severity: number }>;
|
||||
}> = [
|
||||
{
|
||||
offset: 5,
|
||||
offset: FLARE_ONSET_DAYS_AGO,
|
||||
impact: 1,
|
||||
note: "Itchy eyes and sneezing started today.",
|
||||
symptoms: [
|
||||
@@ -1471,7 +1572,7 @@ async function seed() {
|
||||
],
|
||||
},
|
||||
{
|
||||
offset: 4,
|
||||
offset: FLARE_ONSET_DAYS_AGO - 1,
|
||||
impact: 1,
|
||||
note: "Pollen high again — runny nose all morning.",
|
||||
symptoms: [
|
||||
@@ -1480,7 +1581,7 @@ async function seed() {
|
||||
],
|
||||
},
|
||||
{
|
||||
offset: 2,
|
||||
offset: FLARE_ONSET_DAYS_AGO - 3,
|
||||
impact: 1,
|
||||
note: "Antihistamine taking the edge off.",
|
||||
symptoms: [
|
||||
@@ -1489,7 +1590,7 @@ async function seed() {
|
||||
],
|
||||
},
|
||||
{
|
||||
offset: 0,
|
||||
offset: FLARE_ONSET_DAYS_AGO - 6,
|
||||
impact: 1,
|
||||
note: "Still sniffly but manageable.",
|
||||
symptoms: [{ key: "runny_nose", severity: 1 }],
|
||||
@@ -1705,6 +1806,142 @@ async function seed() {
|
||||
],
|
||||
);
|
||||
|
||||
// ── Visits, a procedure and its documents ──
|
||||
// Two practitioners, a past knee arthroscopy with its two letters linked,
|
||||
// the follow-up visit that came after it, and a check-up booked for
|
||||
// tomorrow morning. The booked visit is what the start page's Today
|
||||
// overview and worth-a-look rail read; the procedure with linked files is
|
||||
// what gives the documents page its procedure filter. Documents are real
|
||||
// (tiny) PDFs, encrypted with the binary codec the upload path writes.
|
||||
console.log("Creating visits, a procedure and its documents...");
|
||||
const familyPracticeId = cuid();
|
||||
const orthopaedicsId = cuid();
|
||||
for (const [id, name, specialty] of [
|
||||
[familyPracticeId, "Family practice", "General practice"],
|
||||
[orthopaedicsId, "Orthopaedic clinic", "Orthopaedics"],
|
||||
]) {
|
||||
await client.query(
|
||||
`INSERT INTO practitioners (id, user_id, name, specialty, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, NOW(), NOW())`,
|
||||
[id, userId, name, specialty],
|
||||
);
|
||||
}
|
||||
const insertEncounter = async (params: {
|
||||
occurredAt: Date;
|
||||
status: "DONE" | "PLANNED";
|
||||
kind: "ROUTINE" | "PROCEDURE";
|
||||
practitionerId: string;
|
||||
reason: string;
|
||||
outcome?: string;
|
||||
bodySite?: string;
|
||||
laterality?: "LEFT" | "RIGHT";
|
||||
}): Promise<string> => {
|
||||
const id = cuid();
|
||||
await client.query(
|
||||
`INSERT INTO encounters (id, user_id, occurred_at, status, kind, practitioner_id, reason_encrypted, outcome_encrypted, body_site_encrypted, laterality, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4::encounter_status, $5::encounter_kind, $6, $7, $8, $9, $10::laterality, NOW(), NOW())`,
|
||||
[
|
||||
id,
|
||||
userId,
|
||||
params.occurredAt,
|
||||
params.status,
|
||||
params.kind,
|
||||
params.practitionerId,
|
||||
encBytes(params.reason),
|
||||
params.outcome ? encBytes(params.outcome) : null,
|
||||
params.bodySite ? encBytes(params.bodySite) : null,
|
||||
params.laterality ?? null,
|
||||
],
|
||||
);
|
||||
return id;
|
||||
};
|
||||
const procedureAt = daysAgoAt(150, 8, 30);
|
||||
const procedureId = await insertEncounter({
|
||||
occurredAt: procedureAt,
|
||||
status: "DONE",
|
||||
kind: "PROCEDURE",
|
||||
practitionerId: orthopaedicsId,
|
||||
reason: "Arthroscopy for a torn meniscus after a running injury.",
|
||||
outcome:
|
||||
"Partial meniscectomy, day surgery. Physiotherapy for six weeks, " +
|
||||
"back to easy running after that.",
|
||||
bodySite: "knee",
|
||||
laterality: "LEFT",
|
||||
});
|
||||
await insertEncounter({
|
||||
occurredAt: daysAgoAt(136, 10, 15),
|
||||
status: "DONE",
|
||||
kind: "ROUTINE",
|
||||
practitionerId: orthopaedicsId,
|
||||
reason: "Follow-up two weeks after the arthroscopy.",
|
||||
outcome: "Healing well, full range of motion. Physiotherapy continues.",
|
||||
});
|
||||
await insertEncounter({
|
||||
occurredAt: daysAgoAt(-1, 9, 30),
|
||||
status: "PLANNED",
|
||||
kind: "ROUTINE",
|
||||
practitionerId: familyPracticeId,
|
||||
reason: "Annual check-up with a blood panel.",
|
||||
});
|
||||
const procedureDocs: Array<{
|
||||
kind: "DOCTOR_REPORT" | "DISCHARGE_LETTER";
|
||||
title: string;
|
||||
filename: string;
|
||||
date: Date;
|
||||
lines: string[];
|
||||
}> = [
|
||||
{
|
||||
kind: "DOCTOR_REPORT",
|
||||
title: "Operative report, knee arthroscopy",
|
||||
filename: "operative-report-knee-arthroscopy.pdf",
|
||||
date: procedureAt,
|
||||
lines: [
|
||||
"Operative report",
|
||||
"Procedure: arthroscopic partial meniscectomy, left knee",
|
||||
"Anaesthesia: general, day surgery",
|
||||
"Findings: radial tear of the medial meniscus, cartilage intact",
|
||||
"Course: uneventful",
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: "DISCHARGE_LETTER",
|
||||
title: "Discharge letter, day surgery",
|
||||
filename: "discharge-letter-day-surgery.pdf",
|
||||
date: procedureAt,
|
||||
lines: [
|
||||
"Discharge letter",
|
||||
"Discharged the same afternoon in good condition",
|
||||
"Weight-bearing as tolerated, crutches for three days",
|
||||
"Physiotherapy: twice a week for six weeks",
|
||||
"Follow-up in the clinic after two weeks",
|
||||
],
|
||||
},
|
||||
];
|
||||
for (const doc of procedureDocs) {
|
||||
const pdf = simplePdf(doc.lines);
|
||||
const docId = cuid();
|
||||
await client.query(
|
||||
`INSERT INTO inbound_documents (id, user_id, kind, title, filename, mime_type, byte_size, content_encrypted, content_sha256, content_codec, status, document_date, created_at, updated_at)
|
||||
VALUES ($1, $2, $3::inbound_document_kind, $4, $5, 'application/pdf', $6, $7, $8, 'binary2', 'STORED', $9, $9, $9)`,
|
||||
[
|
||||
docId,
|
||||
userId,
|
||||
doc.kind,
|
||||
doc.title,
|
||||
doc.filename,
|
||||
pdf.byteLength,
|
||||
Buffer.from(encryptBytes(pdf)),
|
||||
createHash("sha256").update(pdf).digest("hex"),
|
||||
doc.date,
|
||||
],
|
||||
);
|
||||
await client.query(
|
||||
`INSERT INTO encounter_document_links (id, encounter_id, document_id, user_id, created_at)
|
||||
VALUES ($1, $2, $3, $4, NOW())`,
|
||||
[cuid(), procedureId, docId, userId],
|
||||
);
|
||||
}
|
||||
|
||||
// ── Achievements ─────────────────────────
|
||||
console.log("Creating achievements...");
|
||||
|
||||
@@ -1752,93 +1989,280 @@ async function seed() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Coach sample conversation ─────────────
|
||||
// A short, encrypted two-turn exchange so the Coach surface shows real
|
||||
// content. Messages use the same AES-256-GCM Bytes codec the app writes
|
||||
// with (encryptToBytes → encrypt() under ENCRYPTION_KEYS), so they decrypt
|
||||
// exactly like a live conversation — no faked / plaintext rows.
|
||||
console.log("Creating Coach sample conversation...");
|
||||
const convoId = cuid();
|
||||
const convoStart = daysAgo(3);
|
||||
await client.query(
|
||||
`INSERT INTO coach_conversations (id, user_id, title, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $4)`,
|
||||
[convoId, userId, "Blood pressure trend", convoStart],
|
||||
);
|
||||
// ── Coach sample conversations ────────────
|
||||
// Five short, encrypted exchanges spread from today back over a few
|
||||
// weeks, so the history rail groups them by day the way a used account
|
||||
// reads, and the thread that opens first (today's) carries a full answer
|
||||
// with the steps the Coach took. Messages use the same AES-256-GCM Bytes
|
||||
// codec the app writes with (encryptToBytes → encrypt() under
|
||||
// ENCRYPTION_KEYS), so they decrypt exactly like a live conversation — no
|
||||
// faked / plaintext rows.
|
||||
console.log("Creating Coach sample conversations...");
|
||||
|
||||
// The provenance uses the contract tokens (`bp`, `resting_hr`, `sleep`):
|
||||
// the client names an area only through a known token, so a token it
|
||||
// cannot name never reaches the page. The steps give the answer its
|
||||
// "Looked at 3 sources" header the way a live turn would; they carry no
|
||||
// "Looked at N sources" header the way a live turn would; they carry no
|
||||
// counts, since a count is the server's tally and the seed does not
|
||||
// run a tool.
|
||||
const WINDOW_LABEL = {
|
||||
last7days: "last 7 days",
|
||||
last30days: "last 30 days",
|
||||
last90days: "last 90 days",
|
||||
} as const;
|
||||
const seedStep = (
|
||||
id: string,
|
||||
tool: string,
|
||||
domain: string,
|
||||
domainLabel: string,
|
||||
window: keyof typeof WINDOW_LABEL = "last30days",
|
||||
) => ({
|
||||
id,
|
||||
tool,
|
||||
labelKey: "coach.step.readWindow",
|
||||
label: `Checking: ${domainLabel}, last 30 days`,
|
||||
label: `Checking: ${domainLabel}, ${WINDOW_LABEL[window]}`,
|
||||
domain,
|
||||
window: "last30days",
|
||||
window,
|
||||
status: "done",
|
||||
});
|
||||
const coachTurns: Array<{
|
||||
role: string;
|
||||
content: string;
|
||||
metricSourceJson?: string;
|
||||
tokensUsed?: number;
|
||||
model?: string;
|
||||
type SeedStep = ReturnType<typeof seedStep>;
|
||||
const answer = (
|
||||
content: string,
|
||||
window: SeedStep["window"],
|
||||
steps: SeedStep[],
|
||||
tokensUsed: number,
|
||||
) => ({
|
||||
role: "assistant",
|
||||
content,
|
||||
metricSourceJson: JSON.stringify({
|
||||
windows: [window],
|
||||
metrics: [...new Set(steps.map((step) => step.domain))],
|
||||
steps,
|
||||
}),
|
||||
tokensUsed,
|
||||
model: aiModel,
|
||||
});
|
||||
const coachConversations: Array<{
|
||||
title: string;
|
||||
startedAt: Date;
|
||||
turns: Array<{
|
||||
role: string;
|
||||
content: string;
|
||||
metricSourceJson?: string;
|
||||
tokensUsed?: number;
|
||||
model?: string;
|
||||
}>;
|
||||
}> = [
|
||||
{
|
||||
role: "user",
|
||||
content: "How has my blood pressure been trending lately?",
|
||||
title: "Recovery this week",
|
||||
// Today, shortly before the seed ran, so it is the newest thread.
|
||||
startedAt: new Date(Date.now() - 50 * 60_000),
|
||||
turns: [
|
||||
{
|
||||
role: "user",
|
||||
content:
|
||||
"How well have I been recovering this week, and is there anything I should change?",
|
||||
},
|
||||
answer(
|
||||
"This has been a well-recovered week. You slept close to seven " +
|
||||
"and a half hours on most nights, with a steady share of deep " +
|
||||
"sleep, and your resting heart rate stayed in the high fifties, " +
|
||||
"which is where it has sat for the past month.\n\n" +
|
||||
"Heart-rate variability tells the same story: it held inside " +
|
||||
"your usual range on every day this week, and the two harder " +
|
||||
"runs did not push it down the next morning. Your step count " +
|
||||
"averaged around nine thousand a day, so the load is there " +
|
||||
"without tipping into strain.\n\n" +
|
||||
"Nothing here asks for a change. If you want one thing to keep " +
|
||||
"an eye on, it is the later bedtimes on the weekend: the two " +
|
||||
"shortest nights of the week both fell on a Saturday or Sunday.",
|
||||
"last7days",
|
||||
[
|
||||
seedStep("s1", "get_sleep", "sleep", "Sleep", "last7days"),
|
||||
seedStep(
|
||||
"s2",
|
||||
"get_metric_series",
|
||||
"resting_hr",
|
||||
"Resting HR",
|
||||
"last7days",
|
||||
),
|
||||
seedStep("s3", "get_metric_series", "hrv", "HRV", "last7days"),
|
||||
seedStep(
|
||||
"s4",
|
||||
"get_metric_series",
|
||||
"steps",
|
||||
"Steps",
|
||||
"last7days",
|
||||
),
|
||||
seedStep(
|
||||
"s5",
|
||||
"get_workouts",
|
||||
"workouts",
|
||||
"Workouts",
|
||||
"last7days",
|
||||
),
|
||||
],
|
||||
2630,
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content:
|
||||
"Your blood pressure has been settling nicely. Over the last few " +
|
||||
"weeks the morning readings have drifted into the optimal band, and " +
|
||||
"your resting heart rate is sitting in a comfortable range too. " +
|
||||
"Keeping up the steady sleep and daily movement looks like it is " +
|
||||
"paying off — worth staying consistent with the morning measurements " +
|
||||
"so the trend stays easy to read.",
|
||||
metricSourceJson: JSON.stringify({
|
||||
windows: ["last30days"],
|
||||
metrics: ["bp", "resting_hr", "sleep"],
|
||||
steps: [
|
||||
seedStep("s1", "get_metric_series", "bp", "Blood pressure"),
|
||||
seedStep("s2", "get_metric_series", "resting_hr", "Resting HR"),
|
||||
seedStep("s3", "get_sleep", "sleep", "Sleep"),
|
||||
],
|
||||
}),
|
||||
tokensUsed: 1840,
|
||||
model: aiModel,
|
||||
title: "Resting heart rate",
|
||||
startedAt: daysAgoAt(1, 19, 40),
|
||||
turns: [
|
||||
{ role: "user", content: "Is my resting heart rate improving?" },
|
||||
answer(
|
||||
"Yes, gradually. Three months ago it sat in the low sixties; over " +
|
||||
"the last few weeks it has settled in the high fifties and " +
|
||||
"stayed there. That kind of slow, steady drift usually follows " +
|
||||
"regular aerobic training, which matches the running in your log.",
|
||||
"last90days",
|
||||
[
|
||||
seedStep(
|
||||
"s1",
|
||||
"get_metric_series",
|
||||
"resting_hr",
|
||||
"Resting HR",
|
||||
"last90days",
|
||||
),
|
||||
seedStep(
|
||||
"s2",
|
||||
"get_workouts",
|
||||
"workouts",
|
||||
"Workouts",
|
||||
"last90days",
|
||||
),
|
||||
],
|
||||
1520,
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Blood pressure trend",
|
||||
startedAt: daysAgo(3),
|
||||
turns: [
|
||||
{
|
||||
role: "user",
|
||||
content: "How has my blood pressure been trending lately?",
|
||||
},
|
||||
answer(
|
||||
"Your blood pressure has been settling nicely. Over the last few " +
|
||||
"weeks the morning readings have drifted into the optimal band, and " +
|
||||
"your resting heart rate is sitting in a comfortable range too. " +
|
||||
"Keeping up the steady sleep and daily movement looks like it is " +
|
||||
"paying off — worth staying consistent with the morning measurements " +
|
||||
"so the trend stays easy to read.",
|
||||
"last30days",
|
||||
[
|
||||
seedStep("s1", "get_metric_series", "bp", "Blood pressure"),
|
||||
seedStep("s2", "get_metric_series", "resting_hr", "Resting HR"),
|
||||
seedStep("s3", "get_sleep", "sleep", "Sleep"),
|
||||
],
|
||||
1840,
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Back to running after the knee",
|
||||
startedAt: daysAgoAt(9, 7, 55),
|
||||
turns: [
|
||||
{
|
||||
role: "user",
|
||||
content:
|
||||
"Am I back to my old activity level since the knee surgery?",
|
||||
},
|
||||
answer(
|
||||
"Yes. Across the last three months you have run, cycled and done " +
|
||||
"strength sessions three to four times a week, and your daily " +
|
||||
"steps have climbed from around six and a half thousand to about " +
|
||||
"nine thousand. Your resting heart rate came down while the load " +
|
||||
"went up, which suggests the build-up after the arthroscopy was " +
|
||||
"well paced.",
|
||||
"last90days",
|
||||
[
|
||||
seedStep(
|
||||
"s1",
|
||||
"get_metric_series",
|
||||
"steps",
|
||||
"Steps",
|
||||
"last90days",
|
||||
),
|
||||
seedStep(
|
||||
"s2",
|
||||
"get_workouts",
|
||||
"workouts",
|
||||
"Workouts",
|
||||
"last90days",
|
||||
),
|
||||
],
|
||||
1710,
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Sleep and mood",
|
||||
startedAt: daysAgoAt(24, 21, 10),
|
||||
turns: [
|
||||
{
|
||||
role: "user",
|
||||
content: "Do my sleep and my mood move together?",
|
||||
},
|
||||
answer(
|
||||
"Somewhat. On the mornings after nights shorter than about six " +
|
||||
"and a half hours, your mood entries were a little lower on " +
|
||||
"average. It is a loose pattern across a month of data, worth " +
|
||||
"watching rather than a cause and effect.",
|
||||
"last30days",
|
||||
[
|
||||
seedStep("s1", "get_sleep", "sleep", "Sleep"),
|
||||
seedStep("s2", "get_metric_series", "mood", "Mood"),
|
||||
],
|
||||
1390,
|
||||
),
|
||||
],
|
||||
},
|
||||
];
|
||||
for (let t = 0; t < coachTurns.length; t++) {
|
||||
const turn = coachTurns[t];
|
||||
const ts = new Date(convoStart.getTime() + t * 60_000);
|
||||
const encrypted = Buffer.from(encryptToBytes(turn.content));
|
||||
for (const convo of coachConversations) {
|
||||
const convoId = cuid();
|
||||
const lastAt = new Date(
|
||||
convo.startedAt.getTime() + (convo.turns.length - 1) * 60_000,
|
||||
);
|
||||
// The title is sealed the way the app writes it since v1.39.3. A
|
||||
// readable `title` is picked up by the boot backfill, whose update
|
||||
// also moves `updated_at` to the moment it ran, and every thread then
|
||||
// sorted under "Today".
|
||||
await client.query(
|
||||
`INSERT INTO coach_messages (id, conversation_id, role, encrypted_content, metric_source_json, provider_type, prompt_version, tokens_used, model, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
||||
`INSERT INTO coach_conversations (id, user_id, title_encrypted, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[
|
||||
cuid(),
|
||||
convoId,
|
||||
turn.role,
|
||||
encrypted,
|
||||
turn.metricSourceJson ?? null,
|
||||
turn.role === "assistant" ? aiProvider : null,
|
||||
turn.role === "assistant" ? "demo" : null,
|
||||
turn.tokensUsed ?? null,
|
||||
turn.model ?? null,
|
||||
ts,
|
||||
userId,
|
||||
Buffer.from(encryptToBytes(convo.title)),
|
||||
convo.startedAt,
|
||||
lastAt,
|
||||
],
|
||||
);
|
||||
for (let t = 0; t < convo.turns.length; t++) {
|
||||
const turn = convo.turns[t];
|
||||
const ts = new Date(convo.startedAt.getTime() + t * 60_000);
|
||||
const encrypted = Buffer.from(encryptToBytes(turn.content));
|
||||
await client.query(
|
||||
`INSERT INTO coach_messages (id, conversation_id, role, encrypted_content, metric_source_json, provider_type, prompt_version, tokens_used, model, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
||||
[
|
||||
cuid(),
|
||||
convoId,
|
||||
turn.role,
|
||||
encrypted,
|
||||
turn.metricSourceJson ?? null,
|
||||
turn.role === "assistant" ? aiProvider : null,
|
||||
turn.role === "assistant" ? "demo" : null,
|
||||
turn.tokensUsed ?? null,
|
||||
turn.model ?? null,
|
||||
ts,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Baked AI insight texts ────────────────
|
||||
@@ -2240,10 +2664,24 @@ async function seed() {
|
||||
|
||||
// ── App Settings ─────────────────────────
|
||||
console.log("Creating app settings...");
|
||||
// The key-backup step is confirmed for the key the demo runs with, the
|
||||
// same fields `POST /api/admin/encryption/key-backup/confirm` writes (key
|
||||
// id + its public fingerprint, never the key). The demo account is an
|
||||
// admin, so an unconfirmed step put a "Back up your encryption key"
|
||||
// banner above the dashboard for every visitor, about a key no visitor
|
||||
// can reach.
|
||||
const activeKeyId = getActiveKeyId();
|
||||
await client.query(
|
||||
`INSERT INTO app_settings (id, registration_enabled, default_locale)
|
||||
VALUES ('singleton', false, 'en')
|
||||
ON CONFLICT (id) DO UPDATE SET registration_enabled = false, default_locale = 'en'`,
|
||||
`INSERT INTO app_settings (id, registration_enabled, default_locale,
|
||||
encryption_key_backup_confirmed_at, encryption_key_backup_confirmed_key_id,
|
||||
encryption_key_backup_confirmed_fingerprint, encryption_key_backup_confirmed_by_user_id)
|
||||
VALUES ('singleton', false, 'en', NOW(), $1, $2, $3)
|
||||
ON CONFLICT (id) DO UPDATE SET registration_enabled = false, default_locale = 'en',
|
||||
encryption_key_backup_confirmed_at = EXCLUDED.encryption_key_backup_confirmed_at,
|
||||
encryption_key_backup_confirmed_key_id = EXCLUDED.encryption_key_backup_confirmed_key_id,
|
||||
encryption_key_backup_confirmed_fingerprint = EXCLUDED.encryption_key_backup_confirmed_fingerprint,
|
||||
encryption_key_backup_confirmed_by_user_id = EXCLUDED.encryption_key_backup_confirmed_by_user_id`,
|
||||
[activeKeyId, getKeyFingerprint(activeKeyId), userId],
|
||||
);
|
||||
|
||||
// ── Audit Log (some login entries) ──────
|
||||
@@ -2292,12 +2730,15 @@ async function seed() {
|
||||
` Lab panels: ${labResults.length} rows across 2 panels (incl. ${recentQual.length} qualitative)`,
|
||||
);
|
||||
console.log(
|
||||
` Illness: resolved cold (day-logs) + active chronic condition with a flare`,
|
||||
` Illness: resolved cold (day-logs) + recurring condition with a resolved flare`,
|
||||
);
|
||||
console.log(
|
||||
` Cycle: ${cycleLengths.length} cycles with BBT, flow, mucus/OPK, symptoms + a forecast`,
|
||||
);
|
||||
console.log(` Coach: 1 conversation (${coachTurns.length} messages)`);
|
||||
console.log(
|
||||
` Visits: knee arthroscopy (2 linked PDFs) + follow-up, check-up booked for tomorrow`,
|
||||
);
|
||||
console.log(` Coach: ${coachConversations.length} conversations`);
|
||||
console.log(
|
||||
` Baked AI texts: comprehensive + daily briefing, ${statusCards.length} status cards, ${periodNarratives.length} period narratives (en)`,
|
||||
);
|
||||
|
||||
@@ -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*["'`]/);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* A write that is not activity keeps the row's `updatedAt`.
|
||||
*
|
||||
* Prisma's `@updatedAt` stamps "now" on every `update` / `updateMany` /
|
||||
* `upsert` that does not name the column. That is right for an edit and
|
||||
* wrong for a one-shot rewrite of how a value is stored: the title
|
||||
* encryption backfill bumped every Coach conversation it touched, and the
|
||||
* Coach panel, which groups and orders conversations by `updatedAt`, then
|
||||
* showed months-old threads under "Today". The same column is the sync
|
||||
* cursor for measurements and mood entries, so a storage-only rewrite there
|
||||
* made every client pull rows that had not changed.
|
||||
*
|
||||
* Two freezes:
|
||||
*
|
||||
* 1. Every Prisma write in a backfill (`lib/jobs/*-backfill.ts`,
|
||||
* `scripts/backfill-*.ts`) on a model with an `@updatedAt` column names
|
||||
* `updatedAt` in the call (carrying the row's own value forward), or is
|
||||
* listed below with the reason the stamp is wanted.
|
||||
* 2. Every `coachConversation` write anywhere names `updatedAt`, or is
|
||||
* listed as something the person did in the conversation. Background
|
||||
* bookkeeping (the memory summary) writes through raw SQL, which Prisma
|
||||
* does not stamp.
|
||||
*
|
||||
* A tripwire, not a proof: it cannot tell whether a listed reason is still
|
||||
* true, only that the set did not change without someone editing this file.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const SRC = join(ROOT, "src");
|
||||
|
||||
/** Models (as Prisma client accessors) that carry an `@updatedAt` column. */
|
||||
function modelsWithUpdatedAt(): Set<string> {
|
||||
const schema = readFileSync(join(ROOT, "prisma/schema.prisma"), "utf8");
|
||||
const out = new Set<string>();
|
||||
for (const m of schema.matchAll(/^model\s+(\w+)\s*\{([\s\S]*?)^\}/gm)) {
|
||||
if (/@updatedAt\b/.test(m[2])) {
|
||||
out.add(m[1][0].toLowerCase() + m[1].slice(1));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const WRITE_RE = /\.\s*([a-zA-Z]+)\s*\.\s*(update|updateMany|upsert)\s*\(/g;
|
||||
|
||||
/** The argument text of the call whose `(` sits at `open`. */
|
||||
function callArgs(source: string, open: number): string {
|
||||
let depth = 0;
|
||||
for (let i = open; i < source.length; i++) {
|
||||
const c = source[i];
|
||||
if (c === "(") depth++;
|
||||
else if (c === ")") {
|
||||
depth--;
|
||||
if (depth === 0) return source.slice(open, i + 1);
|
||||
}
|
||||
}
|
||||
return source.slice(open);
|
||||
}
|
||||
|
||||
interface Write {
|
||||
file: string;
|
||||
model: string;
|
||||
namesUpdatedAt: boolean;
|
||||
}
|
||||
|
||||
function writesIn(file: string, source: string): Write[] {
|
||||
const out: Write[] = [];
|
||||
for (const m of source.matchAll(WRITE_RE)) {
|
||||
const open = (m.index ?? 0) + m[0].length - 1;
|
||||
out.push({
|
||||
file,
|
||||
model: m[1],
|
||||
namesUpdatedAt: /\bupdatedAt\s*:/.test(callArgs(source, open)),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function backfillFiles(): string[] {
|
||||
const jobs = readdirSync(join(SRC, "lib/jobs"))
|
||||
.filter((f) => /-backfill\.ts$/.test(f))
|
||||
.map((f) => `src/lib/jobs/${f}`);
|
||||
const scripts = readdirSync(join(ROOT, "scripts"))
|
||||
.filter((f) => /^backfill-.*\.ts$/.test(f))
|
||||
.map((f) => `scripts/${f}`);
|
||||
return [...jobs, ...scripts].sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Backfill writes that stamp `updatedAt` on purpose, keyed `file > model`.
|
||||
*/
|
||||
const BACKFILL_STAMPS: Record<string, string> = {
|
||||
"src/lib/jobs/fitbit-backfill.ts > fitbitConnection":
|
||||
"the job's own progress on the connection row; not a listed record",
|
||||
"src/lib/jobs/google-health-backfill.ts > googleHealthConnection":
|
||||
"the job's own progress on the connection row; not a listed record",
|
||||
"src/lib/jobs/whoop-backfill.ts > whoopConnection":
|
||||
"the job's own progress on the connection row; not a listed record",
|
||||
"src/lib/jobs/sleep-timeline-backfill.ts > whoopConnection":
|
||||
"marks the connection's one-shot as done; not a listed record",
|
||||
"src/lib/jobs/sleep-timeline-backfill.ts > withingsConnection":
|
||||
"marks the connection's one-shot as done; not a listed record",
|
||||
"src/lib/jobs/strava-backfill.ts > user":
|
||||
"stamps the account's backfill-completed marker",
|
||||
"src/lib/jobs/document-content-index-backfill.ts > documentContentIndex":
|
||||
"the index row is rebuilt content; its updatedAt is the rebuild time",
|
||||
"src/lib/jobs/lab-biomarker-backfill.ts > biomarker":
|
||||
"shared catalogue row created or refreshed by the backfill",
|
||||
"src/lib/jobs/lab-biomarker-backfill.ts > labResult":
|
||||
"bulk link to a catalogue id; no lab surface orders by updatedAt and a bulk write cannot carry each row's own value",
|
||||
"src/lib/jobs/free-text-encryption-backfill.ts > measurementReminder":
|
||||
"bulk clear of a retired readable copy; reminders are listed by schedule, not by updatedAt",
|
||||
};
|
||||
|
||||
/**
|
||||
* `coachConversation` writes outside backfills that stamp `updatedAt`
|
||||
* 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, { 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", () => {
|
||||
const stamped = modelsWithUpdatedAt();
|
||||
const files = backfillFiles();
|
||||
const writes = files.flatMap((f) =>
|
||||
writesIn(f, readFileSync(join(ROOT, f), "utf8")),
|
||||
);
|
||||
|
||||
it("reads the schema and the backfills at all", () => {
|
||||
// An empty match set would agree with any allowlist.
|
||||
expect(stamped.size).toBeGreaterThanOrEqual(40);
|
||||
expect(stamped.has("coachConversation")).toBe(true);
|
||||
expect(files.length).toBeGreaterThanOrEqual(10);
|
||||
expect(writes.length).toBeGreaterThanOrEqual(15);
|
||||
});
|
||||
|
||||
it("carries updatedAt through every storage-only rewrite", () => {
|
||||
const offenders = writes
|
||||
.filter((w) => stamped.has(w.model) && !w.namesUpdatedAt)
|
||||
.map((w) => `${w.file} > ${w.model}`)
|
||||
.filter((key) => !(key in BACKFILL_STAMPS));
|
||||
expect(
|
||||
[...new Set(offenders)],
|
||||
"a backfill write bumps updatedAt: carry the row's own value (`updatedAt: fresh.updatedAt`), or list it with the reason the stamp is wanted",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("has no stale entries", () => {
|
||||
const seen = new Set(
|
||||
writes
|
||||
.filter((w) => stamped.has(w.model) && !w.namesUpdatedAt)
|
||||
.map((w) => `${w.file} > ${w.model}`),
|
||||
);
|
||||
expect(Object.keys(BACKFILL_STAMPS).filter((k) => !seen.has(k))).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("coach conversations move only on activity", () => {
|
||||
const files = walkSourceFiles(SRC, { floor: 3000 })
|
||||
.filter((p) => !p.startsWith("generated/"))
|
||||
.filter((p) => !p.includes("__tests__"))
|
||||
.map((p) => `src/${p}`);
|
||||
const writes = [
|
||||
...files.flatMap((f) => writesIn(f, readFileSync(join(ROOT, f), "utf8"))),
|
||||
...backfillFiles()
|
||||
.filter((f) => f.startsWith("scripts/"))
|
||||
.flatMap((f) => writesIn(f, readFileSync(join(ROOT, f), "utf8"))),
|
||||
].filter((w) => w.model === "coachConversation");
|
||||
|
||||
it("finds the conversation writes at all", () => {
|
||||
expect(writes.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it("names updatedAt in every write that is not the person's own action", () => {
|
||||
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(
|
||||
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(
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Key rotation re-seals values in place across every encrypted model, through
|
||||
* generic delegates the model-named matcher above cannot see. Every write it
|
||||
* makes names `updatedAt`, carrying the row's own value where the model has
|
||||
* one; re-sealing under a new key is not an edit.
|
||||
*/
|
||||
describe("key rotation keeps updatedAt", () => {
|
||||
const ROTATION_FILES = [
|
||||
"src/lib/crypto/encryption-corpus.ts",
|
||||
"scripts/rotate-encryption-key.ts",
|
||||
];
|
||||
const ANY_WRITE_RE = /\.\s*(update|updateMany|upsert)\s*\(/g;
|
||||
|
||||
const calls = ROTATION_FILES.flatMap((file) => {
|
||||
const source = readFileSync(join(ROOT, file), "utf8");
|
||||
return [...source.matchAll(ANY_WRITE_RE)].map((m) => ({
|
||||
file,
|
||||
args: callArgs(source, (m.index ?? 0) + m[0].length - 1),
|
||||
}));
|
||||
});
|
||||
|
||||
it("finds the rotation writes at all", () => {
|
||||
expect(calls.length).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
|
||||
it("names updatedAt in every rotation write", () => {
|
||||
const offenders = calls
|
||||
.filter((c) => !/updatedAt/i.test(c.args))
|
||||
.map((c) => `${c.file}: ${c.args.slice(0, 80).replace(/\s+/g, " ")}`);
|
||||
expect(
|
||||
offenders,
|
||||
"a rotation write must carry the row's own updatedAt",
|
||||
).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,
|
||||
|
||||
@@ -70,6 +70,7 @@ const DIGEST: DailyDigest = {
|
||||
composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"],
|
||||
},
|
||||
topSignal: null,
|
||||
signalLine: null,
|
||||
briefingLead: "Blood pressure is holding steady.",
|
||||
lead: { text: "Blood pressure is holding steady.", source: "briefing" },
|
||||
today: [],
|
||||
|
||||
@@ -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";
|
||||
@@ -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"),
|
||||
|
||||
@@ -212,17 +212,17 @@ describe("a reuse chip", () => {
|
||||
messageId: h.LAST_ASSISTANT_ID,
|
||||
ref: "r1",
|
||||
});
|
||||
const [chips] = framesOf<{ followUps: CoachFollowUp[] }>(
|
||||
run.frames,
|
||||
"followUps",
|
||||
);
|
||||
expect(chips.followUps.map((c) => c.kind)).toEqual(["as_chart"]);
|
||||
// The result renders with its own chart/table toggle, so no chip
|
||||
// offers the chart again.
|
||||
expect(
|
||||
framesOf<{ followUps: CoachFollowUp[] }>(run.frames, "followUps"),
|
||||
).toEqual([]);
|
||||
expect(m.appendMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ role: "assistant", providerType: "reuse" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("answers 'as a chart' with the stored chart and offers the table view", async () => {
|
||||
it("answers 'as a chart' with the stored chart and leaves the table to its toggle", async () => {
|
||||
conversationWithChips();
|
||||
const run = await tap("f3");
|
||||
expect(providerCalls).toHaveLength(0);
|
||||
@@ -231,11 +231,9 @@ describe("a reuse chip", () => {
|
||||
"result",
|
||||
).map((f) => f.result);
|
||||
expect(result.chart).toEqual(world.storedTables[0].chart);
|
||||
const [chips] = framesOf<{ followUps: CoachFollowUp[] }>(
|
||||
run.frames,
|
||||
"followUps",
|
||||
);
|
||||
expect(chips.followUps.map((c) => c.kind)).toEqual(["as_table"]);
|
||||
expect(
|
||||
framesOf<{ followUps: CoachFollowUp[] }>(run.frames, "followUps"),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("still passes the rate limit", async () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -263,22 +263,12 @@ export default function CoachPageClient() {
|
||||
<div
|
||||
data-slot="coach-page"
|
||||
data-tour-id="coach-hero"
|
||||
// v1.18.10 (W4) — full-bleed conversation surface. Cancel the
|
||||
// AuthShell container's padding (`-mt-6` top, `-mb-20` bottom,
|
||||
// `-mx-4 md:-mx-6` sides) so the chat is edge-to-edge and claims the
|
||||
// full viewport height minus the top bar (`4rem`). No inner card:
|
||||
// `<CoachConversation>` paints directly onto the page so it reads as
|
||||
// one continuous conversation, not a window inside a window. The
|
||||
// separate bottom-right FAB/overlay drawer stays the compact surface.
|
||||
//
|
||||
// v1.18.10 (W10) — height is mobile-aware. The fixed `<BottomNav>`
|
||||
// (64px + iOS home-indicator inset) is mobile-only; the desktop
|
||||
// full-bleed layout has no bottom nav. On mobile the page must subtract
|
||||
// the TopBar (`4rem`) AND the BottomNav band (`4rem` +
|
||||
// `env(safe-area-inset-bottom)`) so the docked composer / Stop control
|
||||
// always clears the nav instead of sitting under it. On `md+` the nav
|
||||
// is hidden, so the page reclaims the full height below the TopBar.
|
||||
className="bg-background -mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-8rem-env(safe-area-inset-top,0px)-env(safe-area-inset-bottom,0px))] min-h-[32rem] flex-col overflow-hidden md:-mx-6 md:h-[calc(100dvh-4rem)]"
|
||||
// Full-bleed conversation surface. The shell renders `/coach` without
|
||||
// its centred container, padding or reserved scrollbar gutter, and its
|
||||
// `<main>` already clears the top bar and, on phones, the bottom nav,
|
||||
// so the page simply fills what is left. No inner card: the
|
||||
// conversation paints directly onto the page.
|
||||
className="bg-background flex min-h-[32rem] flex-1 flex-col overflow-hidden"
|
||||
>
|
||||
{/* `useSearchParams` requires a Suspense boundary so the client-search
|
||||
bailout never opts the whole route out of static optimisation. */}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -354,6 +354,7 @@ const DIGEST: DailyDigest = {
|
||||
sleepPending: false,
|
||||
score: null,
|
||||
topSignal: null,
|
||||
signalLine: null,
|
||||
briefingLead: "A steady week so far.",
|
||||
lead: { text: "A steady week so far.", source: "briefing" },
|
||||
today: [],
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import de from "../../../../messages/de.json";
|
||||
import en from "../../../../messages/en.json";
|
||||
import es from "../../../../messages/es.json";
|
||||
import fr from "../../../../messages/fr.json";
|
||||
import it_ from "../../../../messages/it.json";
|
||||
import ko from "../../../../messages/ko.json";
|
||||
import pl from "../../../../messages/pl.json";
|
||||
|
||||
/**
|
||||
* The chart names a series by its localised metric label, never by the enum.
|
||||
*
|
||||
* The label map in the chart covered eleven types and fell back to the raw
|
||||
* type for every other one, so a resting-pulse chart with its trend overlays
|
||||
* on read "RESTING_HEART_RATE" in the trend line, the legend, the tooltip and
|
||||
* the spoken summary. Every overlay series takes its name from the same
|
||||
* helper, so the summary and the trend line stand in for all of them here.
|
||||
*/
|
||||
|
||||
const MESSAGES = { de, en, es, fr, it: it_, ko, pl } as const;
|
||||
|
||||
function buildData(): unknown[] {
|
||||
const out: Array<{
|
||||
date: string;
|
||||
timestamp: number;
|
||||
RESTING_HEART_RATE: number;
|
||||
}> = [];
|
||||
const base = Date.now() - 12 * 3_600_000;
|
||||
for (let i = 0; i < 28; i++) {
|
||||
out.push({
|
||||
date: `p${i}`,
|
||||
timestamp: base - (27 - i) * 86_400_000,
|
||||
RESTING_HEART_RATE: 58 + (i % 5),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ isAuthenticated: true, user: null, isLoading: false }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-chart-overlay-prefs", () => ({
|
||||
useChartOverlayPrefs: () => ({
|
||||
prefs: {
|
||||
showTrendIndicator: true,
|
||||
showTrendArrow: true,
|
||||
showTargetRange: true,
|
||||
comparisonBaseline: "none",
|
||||
},
|
||||
setPrefs: () => undefined,
|
||||
isSaving: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("<HealthChart> — series names", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it.each(Object.keys(MESSAGES) as Array<keyof typeof MESSAGES>)(
|
||||
"names the resting-pulse series in %s, overlays on",
|
||||
async (locale) => {
|
||||
const data = buildData();
|
||||
vi.doMock("@tanstack/react-query", () => ({
|
||||
keepPreviousData: (previous: unknown) => previous,
|
||||
useQuery: () => ({ data, isLoading: false }),
|
||||
useQueryClient: () => ({
|
||||
cancelQueries: () => Promise.resolve(),
|
||||
getQueryData: () => undefined,
|
||||
setQueryData: () => undefined,
|
||||
invalidateQueries: () => Promise.resolve(),
|
||||
}),
|
||||
useMutation: () => ({ mutate: () => undefined, isPending: false }),
|
||||
}));
|
||||
|
||||
const { I18nProvider } = await import("@/lib/i18n/context");
|
||||
const { HealthChart } = await import("../health-chart");
|
||||
const messages = MESSAGES[locale];
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale} initialMessages={messages}>
|
||||
<HealthChart
|
||||
types={["RESTING_HEART_RATE"]}
|
||||
title="Resting pulse"
|
||||
unit="bpm"
|
||||
chartKey="pulse"
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
const label = (
|
||||
messages as unknown as { measurements: Record<string, string> }
|
||||
).measurements.typeRestingHeartRate;
|
||||
expect(label).toBeTruthy();
|
||||
|
||||
const aria = html.match(/role="img"[^>]*aria-label="([^"]*)"/)?.[1];
|
||||
expect(aria).toBeTruthy();
|
||||
expect(aria).not.toContain("RESTING_HEART_RATE");
|
||||
|
||||
const visible = html.replace(/<[^>]+>/g, " ");
|
||||
expect(visible).not.toContain("RESTING_HEART_RATE");
|
||||
expect(visible).toContain(label);
|
||||
|
||||
vi.doUnmock("@tanstack/react-query");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("measurementLabelKey", () => {
|
||||
it("matches the canonical label map for every type outside the chart's own labels", async () => {
|
||||
const { MEASUREMENT_TYPE_LABEL_KEYS } =
|
||||
await import("@/lib/measurements/type-label-keys");
|
||||
const { measurementLabelKey } = await import("../health-chart");
|
||||
// The four types whose key breaks the convention carry a chart label.
|
||||
const chartLabelled = new Set([
|
||||
"WEIGHT",
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
"PULSE",
|
||||
"BODY_FAT",
|
||||
"SLEEP_DURATION",
|
||||
"ACTIVITY_STEPS",
|
||||
"BLOOD_GLUCOSE",
|
||||
"TOTAL_BODY_WATER",
|
||||
"BONE_MASS",
|
||||
"OXYGEN_SATURATION",
|
||||
]);
|
||||
const mismatched = Object.entries(MEASUREMENT_TYPE_LABEL_KEYS)
|
||||
.filter(([type]) => !chartLabelled.has(type))
|
||||
.filter(([type, key]) => measurementLabelKey(type) !== key);
|
||||
expect(mismatched).toEqual([]);
|
||||
expect(Object.keys(MEASUREMENT_TYPE_LABEL_KEYS).length).toBeGreaterThan(50);
|
||||
});
|
||||
});
|
||||
@@ -381,6 +381,27 @@ const BASE_TYPE_LABEL_KEYS: Record<string, string> = {
|
||||
OXYGEN_SATURATION: "charts.spo2",
|
||||
};
|
||||
|
||||
/**
|
||||
* The measurement label key for a type outside the chart's short labels:
|
||||
* `RESTING_HEART_RATE` → `measurements.typeRestingHeartRate`. Derived rather
|
||||
* than imported, because the full label map would ride into every route that
|
||||
* draws a chart; `health-chart-type-label.test.tsx` pins that the derivation
|
||||
* matches `MEASUREMENT_TYPE_LABEL_KEYS` for every type it is used for.
|
||||
*/
|
||||
export function measurementLabelKey(type: string): string {
|
||||
return `measurements.type${type
|
||||
.toLowerCase()
|
||||
.split("_")
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join("")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The name a series carries in the trend line, the legend, the tooltip and
|
||||
* the spoken summary. The chart's own short labels come first; every other
|
||||
* type takes its measurement label, so no overlay ever shows the raw enum
|
||||
* (a resting-pulse trend read "RESTING_HEART_RATE").
|
||||
*/
|
||||
function getTypeLabel(
|
||||
type: string,
|
||||
valueMode: "raw" | "bmi",
|
||||
@@ -390,7 +411,10 @@ function getTypeLabel(
|
||||
return "BMI";
|
||||
}
|
||||
const key = BASE_TYPE_LABEL_KEYS[type];
|
||||
return key ? t(key) : type;
|
||||
if (key) return t(key);
|
||||
const measurementKey = measurementLabelKey(type);
|
||||
const label = t(measurementKey);
|
||||
return label === measurementKey ? type : label;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -41,6 +41,7 @@ function digest(over: Partial<DailyDigest> = {}): DailyDigest {
|
||||
sleepPending: false,
|
||||
score: { value: 82, band: "green", delta: 3 },
|
||||
topSignal: null,
|
||||
signalLine: null,
|
||||
briefingLead: "Your week is trending steady.",
|
||||
lead: { text: "Your week is trending steady.", source: "briefing" },
|
||||
today: [],
|
||||
|
||||
@@ -71,6 +71,12 @@ function digest(over: Partial<DailyDigest> = {}): DailyDigest {
|
||||
nudge: "Take it again after a calm five minutes.",
|
||||
delta: "+6 mmHg vs your 30-day average",
|
||||
},
|
||||
// Resolved on the server: the lead says nothing about blood pressure, so
|
||||
// the whole signal rides under it.
|
||||
signalLine: {
|
||||
headline: "Blood pressure a touch high this morning",
|
||||
delta: "+6 mmHg vs your 30-day average",
|
||||
},
|
||||
briefingLead: "Your week is trending steady.",
|
||||
lead: { text: "Your week is trending steady.", source: "briefing" },
|
||||
today: [],
|
||||
@@ -209,6 +215,7 @@ describe("<TodayHero>", () => {
|
||||
lead: null,
|
||||
briefingLead: null,
|
||||
topSignal: null,
|
||||
signalLine: null,
|
||||
reactionLine: "Score 82 today.",
|
||||
})}
|
||||
/>,
|
||||
@@ -233,6 +240,7 @@ describe("<TodayHero>", () => {
|
||||
nudge: "",
|
||||
delta: null,
|
||||
},
|
||||
signalLine: null,
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
@@ -270,6 +278,7 @@ describe("<TodayHero>", () => {
|
||||
digest={digest({
|
||||
score: null,
|
||||
topSignal: null,
|
||||
signalLine: null,
|
||||
briefingLead: null,
|
||||
lead: null,
|
||||
line: "Nothing needs your attention today — everything's tracking normally.",
|
||||
@@ -289,6 +298,7 @@ describe("<TodayHero>", () => {
|
||||
digest={digest({
|
||||
score: null,
|
||||
topSignal: null,
|
||||
signalLine: null,
|
||||
briefingLead: null,
|
||||
lead: null,
|
||||
line: "Nothing needs your attention today — everything's tracking normally.",
|
||||
@@ -340,6 +350,10 @@ describe("<TodayHero>", () => {
|
||||
text: "Blood pressure a touch high this morning.",
|
||||
source: "briefing",
|
||||
},
|
||||
signalLine: {
|
||||
headline: null,
|
||||
delta: "+6 mmHg vs your 30-day average",
|
||||
},
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
@@ -355,6 +369,7 @@ describe("<TodayHero>", () => {
|
||||
<TodayHero
|
||||
digest={digest({
|
||||
topSignal: null,
|
||||
signalLine: null,
|
||||
briefingLead: null,
|
||||
lead: null,
|
||||
reactionLine: null,
|
||||
@@ -373,11 +388,12 @@ 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({
|
||||
topSignal: null,
|
||||
signalLine: null,
|
||||
worthALook: [],
|
||||
})}
|
||||
/>,
|
||||
@@ -385,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"');
|
||||
});
|
||||
|
||||
@@ -443,6 +460,7 @@ describe("<TodayHero>", () => {
|
||||
digest={digest({
|
||||
score: null,
|
||||
topSignal: null,
|
||||
signalLine: null,
|
||||
briefingLead: null,
|
||||
lead: null,
|
||||
worthALook: [],
|
||||
@@ -570,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 = [
|
||||
@@ -596,6 +694,7 @@ describe("<TodayHero> Today overview", () => {
|
||||
digest={digest({
|
||||
score: null,
|
||||
topSignal: null,
|
||||
signalLine: null,
|
||||
briefingLead: null,
|
||||
lead: null,
|
||||
worthALook: [],
|
||||
@@ -615,6 +714,7 @@ describe("<TodayHero> Today overview", () => {
|
||||
text: "All 4 of your latest vitals sit inside their usual range.",
|
||||
source: "signal",
|
||||
},
|
||||
signalLine: null,
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -74,20 +74,6 @@ function formatDelta(
|
||||
return t("daily.today.deltaVsBaseline", { delta: signed });
|
||||
}
|
||||
|
||||
/** Lowercase letters and digits only, for a wording-insensitive compare. */
|
||||
function normalise(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^\p{L}\p{N}]+/gu, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Whether `lead` already says what `headline` says. */
|
||||
function restates(lead: string, headline: string): boolean {
|
||||
const h = normalise(headline);
|
||||
return h.length > 0 && normalise(lead).includes(h);
|
||||
}
|
||||
|
||||
export function TodayHero({
|
||||
digest,
|
||||
renderFilteredAllClear = false,
|
||||
@@ -148,18 +134,12 @@ export function TodayHero({
|
||||
digest.lead === undefined
|
||||
? (digest.reactionLine ?? digest.briefingLead ?? null)
|
||||
: (digest.lead?.text ?? null);
|
||||
const topSignal = digest.topSignal;
|
||||
// The briefing's top signal rides under an AI lead as its supporting line,
|
||||
// and only with what the lead does not already say: a headline the lead
|
||||
// restates is dropped and its delta, when there is one, stands alone.
|
||||
const signalLine =
|
||||
topSignal && lead && digest.lead?.source !== "signal"
|
||||
? restates(lead, topSignal.headline)
|
||||
? topSignal.delta
|
||||
? { headline: null, delta: topSignal.delta }
|
||||
: null
|
||||
: { headline: topSignal.headline, delta: topSignal.delta }
|
||||
: null;
|
||||
// The line under the lead is decided on the server (`digest.signalLine`):
|
||||
// the briefing's top signal minus whatever the lead already says, so the
|
||||
// headline goes when the lead talks about its metric and the delta stands
|
||||
// alone. A digest the service worker cached before the field existed simply
|
||||
// shows no line.
|
||||
const signalLine = lead ? (digest.signalLine ?? null) : null;
|
||||
// A score-only digest with nothing else to say has no narrative content for
|
||||
// the leading column. Keeping the full md ring in that two-column shell left
|
||||
// a blank column beside a 168 px dial and pushed the all-clear read below
|
||||
@@ -288,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>
|
||||
@@ -305,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
|
||||
@@ -356,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", () => {
|
||||
|
||||