Compare commits

...
127 Commits
Author SHA1 Message Date
Marc-André Bombeck 125b15bfb7 Merge pull request #1176 from MBombeck/release/v1.41.2
v1.41.2
2026-10-07 23:45:20 +02:00
Marc-André Bombeck b184a4bbac chore(release): v1.41.2 2026-10-07 22:48:49 +02:00
Marc-André Bombeck 15b2f6f4de Declare every MCP output field and read live rows in discovery
list_metrics declares availability, the Coach-backed reads declare
searchedWindow and available, and get_intraday_pulse declares min and
max per bucket. An integration test validates every tool's real output
against its advertised schema under a strict 2020-12 validator.

The metric-status discovery rows and the search clinical-signal probe
filter deletedAt, guarded for every measurement read in the MCP and
Coach tool trees.
2026-10-07 22:48:49 +02:00
Marc-André Bombeck 316e52dee3 Widen the Coach turn budget and keep its rounds on the prompt cache
Own-plan turns get 165k tokens, 200 s and 16 rounds, operator turns 55k,
120 s and 8; the final-answer reserve, the concurrent-turn slot and the
step cap move with them.

Codex requests carry a per-conversation prompt_cache_key and a stable
session id, api.openai.com gets the same key, Anthropic usage counts
cache reads and writes in the gross, and local servers report cached
prompt tokens, so the turn budget discounts cached input on every
provider.
2026-10-07 22:48:48 +02:00
Marc-André Bombeck dce53291a4 Merge pull request #1175 from MBombeck/release/v1.41.1
v1.41.1
2026-10-07 17:36:23 +02:00
Marc-André Bombeck 352a36fa47 chore(release): v1.41.1 2026-10-07 16:56:20 +02:00
Marc-André Bombeck dd72634791 Rebuild the Coach thinking row as one calm line
While a turn runs the Coach now shows a single row: a brain icon, the
current step in the answer's text size with a shimmer passing through it
and fading dots, and a chevron once there are steps to open. There is no
running clock any more, and the interim table preview with its sparkline
no longer renders under the row; interim tables stay in state until the
answer's own results show them.

When the answer is done the row reads "Thought process · N steps" with
proper plurals, and "Answer needed" while a clarifying question waits.
It never opens by itself, and a row opened during the run closes when
the turn ends. Opened, the steps form a vertical list with an icon per
kind of work on a thin connector line, and a reasoning round shows its
summary under its title.

The shimmer and the dots are a `text-shimmer` utility and a reusable
WaitingText component, both still under reduced motion.

The demo's write block now carries `meta.errorCode: "demo.readOnly"`,
listed in the error-code catalogue, and the Coach answers it with an
honest line instead of the provider-outage copy.

Removes the interim preview component, the elapsed-seconds and summary
helpers, and their message keys in all seven locales.
2026-10-07 16:55:25 +02:00
Marc-André Bombeck 3df5754ab1 Merge pull request #1172 from MBombeck/release/v1.41.0
v1.41.0
2026-10-06 23:50:57 +02:00
Marc-André Bombeck 42e4292683 docs(changelog): note the dependency advisories 2026-10-06 23:26:51 +02:00
Marc-André Bombeck a3efedfb07 fix(deps): raise the MCP SDK to 1.32 and proxy-addr to 2.0.8 2026-10-06 23:26:40 +02:00
Marc-André Bombeck 4d6c342827 docs(changelog): name the thinking depths as the app does 2026-10-06 23:19:24 +02:00
Marc-André Bombeck 5bbc915073 chore(release): v1.41.0 2026-10-06 23:09:56 +02:00
Marc-André Bombeck 305765194e Merge remote-tracking branch 'origin/v141/integration' into v141/f1-providers 2026-10-06 21:50:22 +02:00
Marc-André Bombeck 1acd41eb58 fix(ai): keep tools on the forced final round and narrow reasoning-dialect learning
The Coach loop's final round sent toolChoice "none" without the tool
definitions. With tool_use/tool_result blocks in the history Anthropic
refuses such a request (400), and OpenAI Chat Completions refuses
tool_choice without tools, so every budget, time, cap and no-progress stop
ended in an error on those wires. The final round now carries the tools
with toolChoice "none", which every wire accepts, thinking included.

Codex kept reasoning items as provider state even when the request asked
for no encrypted reasoning; replaying such an item under store:false is a
400. Items are kept only when the request included
reasoning.encrypted_content and the item carries it.

The reasoning dialect cache learned from any 4xx that mentioned
reasoning or thinking (auth, rate limits, model names such as
deepseek-reasoner) and from replay errors, and kept the downgrade for the
life of the process. Learning now requires an HTTP 400 that refuses a
sent parameter, ignores replay errors and the model name, and a learned
downgrade expires after an hour.
2026-10-06 21:50:19 +02:00
Marc-André Bombeck 49ce23809a Merge held memory writes and trail data fixes into release/v1.41.0 2026-10-06 21:50:01 +02:00
Marc-André Bombeck 5361239ae5 fix(coach): keep a turn's fact and plan only with the answer that carries them
remember_fact and propose_plan wrote their rows during the tool round. An
answer that was then blocked, failed or abandoned left the fact in memory
without its note and Undo, and left the plan listed, activatable and fed
into later turns and the briefing. Both are now staged under an id minted
up front and written in the transaction that stores the assistant message,
only when its provenance carries them. A background proposal counts as
offered only once an answer carrying it is stored.

Plan text is model text: ifCue, thenAction and target pass the outbound
dose and risk screen in the person's locale, with their scheduled doses and
medication names, and the injection screen; a trip declines as "unsafe".

The trail route serves recalled facts and the proposal's fact to the owner
whatever the AI capability (only the model-written entries are gated), and
withholds medication facts while the medications module is off.

Accepting a fact proposal is decided under a per-message advisory lock, so
concurrent taps write one fact. Editing a fact re-reads its wording: text
naming a medication moves to "medication", other health wording moves a
non-health fact to "condition", and a health fact the person edited counts
as confirmed.
2026-10-06 21:49:27 +02:00
Marc-André Bombeck 8fd7e9f7a2 style: draw keyboard focus in a neutral tone everywhere 2026-10-06 21:37:48 +02:00
Marc-André Bombeck bd7a354ddd Merge the Coach interface polish into release/v1.41.0 2026-10-06 21:37:29 +02:00
Marc-André Bombeck fac55720a4 Merge the named-medication dose screen into release/v1.41.0 2026-10-06 21:31:50 +02:00
Marc-André Bombeck 27b94a6bf9 fix(safety): count drug names, class words and spelled counts in the dose screen
The dose-change class demanded a dosage-form noun next to every skip, stop
or extra imperative, and a drug name never counted. "Skip the amlodipine",
"then take an extra ramipril", "take one more tablet of ramipril", "Nimm
abends eine Tablette mehr", "Lass das Ramipril morgen weg" and "Stop your
blood thinner before the run" all passed.

A medication noun is now a dosage-form word, a drug-class word per locale
(blood thinner, Blutverdünner, anticoagulant, statin, insulin, ...), a
common generic-name stem (-pril, -sartan, -olol, -dipine, -formin, ...),
or one of the person's own medication names. The Coach loads the names
with the turn context and passes them to the reply screen and the trail
screen; a scheduled brand name is only known that way.

Add the extra / another / one more shape in en, de, fr, es, it, pl and ko,
spelled-out counts as a dose target, and stop / leave out / go without a
named medication. The number-free shapes use a two-word slot that a
preposition ends, so "take an extra minute to log your medication" and
"stop worrying about your medication" pass. A sentence that only names a
drug, a schedule description, a past change from the log, a negated
warning and a referral ("ask your doctor whether you should stop ...")
still pass.
2026-10-06 21:31:18 +02:00
Marc-André Bombeck e406b9c7ab fix(coach): calm the answer column, its replies and its focus marks
The running status line now carries its seconds right after the text, in
the line's own muted tone, instead of at the far edge of the column. The
live count and every finished round read "4 s", the same spacing as the
settled summary, with a no-break space so a round never wraps between
number and unit.

Reply pills (follow-ups, clarifying choices, memory and plan decisions)
sit directly under the answer, and the action row is the last thing in
the column. Beside a pointer the row stays invisible until hover or
focus; above the pills it left an empty band between an answer and its
replies. Hovering or focusing the message still reveals the row without
moving anything.

Keyboard focus in the Coach panel is a solid ring in the neutral
text-field focus tone, never the purple ring: the pills, Undo and Change
merge it over the button primitive, and the hand-built controls carry it
directly. A structural test keeps the purple ring out of the panel.

A disabled native select now looks disabled, like Input and Select, so a
thinking depth locked by the operator no longer reads as a live choice.
In Settings, thinking depth takes the shape of Tone and Reply length
beside it; a memory entry's Forget is an icon like its edit; the
operator's highest-depth label sits on the switches' left edge.
2026-10-06 21:24:24 +02:00
Marc-André Bombeck c7d659eb11 fix(coach): ask the capability before deferring a memory refresh, and pin test fixtures to the new quiet time 2026-10-06 21:02:41 +02:00
Marc-André Bombeck c56a05925c Merge time-of-day signals and dated latest readings into release/v1.41.0 2026-10-06 20:54:12 +02:00
Marc-André Bombeck 072c1e9394 Compare glucose signals by the hour and date stale strip readings
The signals of the day held today's newest single reading against the
7- and 30-day mean of every reading. For glucose, whose level climbs
with each meal, a fasting morning read as low against an all-day mean
and fed that into the briefing and status prompts. A type in
SAME_HOURS_TYPES now compares today's mean so far with the earlier
days cut at the local time of today's latest reading, the basis the
coincident-deviation flag and the metric strip already use. Below a
week of like-for-like days there is no signal. The signal carries
basis "sameHours" and both briefing prompts say what it means. Other
metrics keep the reading-against-window comparison.

The usual-range strip called the newest reading today's on every
metric page, even when it was days old. CoachReadBaseline now carries
latestDate (the reading's day in the account's zone) and latestIsToday
(isFromToday), on the REST response and the get_metric_baseline MCP
output. The strip names the date when the reading is not from today,
in all seven locales.
2026-10-06 20:53:39 +02:00
Marc-André Bombeck 36d48bb135 chore(i18n): ship the reasoning and memory strings in every locale and drop the retired self-context replies 2026-10-06 20:48:58 +02:00
Marc-André Bombeck 3b5fb1e900 Merge the Coach reasoning interface into release/v1.41.0 2026-10-06 20:48:42 +02:00
Marc-André Bombeck 7b36b09f1f test(e2e): drive the Coach stream frame by frame for the trail, pills and memory
The chat POST is answered from a stream the spec feeds, so each live
state is observed: the status line moving from thinking to fetching to
summarising, a table previewed before the first token, the trail closed
in every state and after a reload, at 1440 and 390 px in both themes.
Clarifying choices, memory and plan decisions answer through pills, an
entry in the memory list is edited in place, and the thinking depth
shows the operator's cap and lock. The dialog spec reads the pills
where it read the card.
2026-10-06 20:47:28 +02:00
Marc-André Bombeck c95dcb34db feat(settings): set how hard the Coach thinks, cap it as an operator, edit its memory
Thinking depth (Off, Low, Medium, High) sits under the model in the
Coach's quick settings and in Settings → Coach. It renders the resolved
block from the account payload: levels above the operator's cap are
disabled and say so, the field locks with one sentence when the
operator switched reasoning off, and Off reads Minimal where the
provider cannot stop thinking. The provider's own reasoning select
leaves the quick settings and says the Coach has its own.

The operator gets a switch to allow deeper thinking and a select for
the highest depth. The memory list shows where each entry came from,
lets it be edited in place, and is linked from the quick settings with
its count. The deep-linked data section no longer draws a ring.
2026-10-06 20:47:27 +02:00
Marc-André Bombeck 11bafd68b5 feat(coach): one quiet status line, reply pills and memory notes in the thread
The top of an answer is one line that says what the Coach is doing right
now (thinking, fetching a metric, summarising the readings, writing),
live from the activity frames, with the seconds so far. It never opens
by itself: the trail stays closed while the turn runs, when it asks,
when it stops early, after the answer and after a reload, and opens
only on a tap. Once the answer is in, the line settles into one summary
of lookups and seconds. A table read during the turn shows as a
one-line preview with a sparkline until the answer takes it over.

Clarifying choices, follow-ups and the answers to a fact or plan
proposal share one pattern: quiet pills in the answer's column whose
tap sends the label as the person's own message. The clarification
card and the in-thread plan card are gone. A fact the Coach kept is one
meta line with Undo, an assumption one line with Change. The remember
button on a message now keeps it in the Coach's memory.
2026-10-06 20:47:17 +02:00
Marc-André Bombeck 4298207500 feat(coach): draw a comparison of two periods or two metrics
The compare chart replaces the placeholder that rendered nothing: two
periods overlaid on one axis (the earlier one dashed), or two metrics on
two axes whose grid lines meet. Same tokens, legend and tooltip as the
other result charts, which stay as they were. The legend key is spelled
out so the chart runtime does not pull the Coach's schemas into every
chart.
2026-10-06 20:47:06 +02:00
Marc-André Bombeck df163123e3 Merge same-hours glucose standing and the updated_at guard into release/v1.41.0 2026-10-06 20:30:35 +02:00
Marc-André Bombeck 3e968f03a9 fix(insights): place a glucose day in progress against the same hours
The usual-range strip on a metric page placed the latest single reading
against a band built from whole-day means. For glucose the day mean climbs
with every meal, so at nine in the morning a fasting reading sat under a
band of whole days and the strip said "below your range" on an ordinary
morning; the MCP baseline read reported the same.

Glucose is now placed by its day mean. A day still in progress, in the
reader's own zone, is held against the earlier days cut at the local time
of today's latest reading, the comparison the health status already makes;
with fewer than seven earlier days that have readings in those hours there
is no verdict. The baseline carries `basis: "sameHours"` on the coach-read
route and the MCP read, and the strip names the range as the one for this
time of day, in all seven locales. Other metrics keep their placement.
2026-10-06 20:29:57 +02:00
Marc-André Bombeck 362818418e test(coach): count the conversation writes that may stamp updatedAt
The guard allowed every unnamed coachConversation write in a listed file,
so a background write added beside the rename and the document attach
would have passed. Each listed file now carries how many such writes it
holds, and a changed count fails.
2026-10-06 20:29:56 +02:00
Marc-André Bombeck e59fef2545 fix(coach): catalog the decline and clarify-choice strings, and let a briefing asked for by hand reason 2026-10-06 20:14:11 +02:00
Marc-André Bombeck 8f9c443d35 Merge dose-change screen into release/v1.41.0 2026-10-06 20:14:04 +02:00
Marc-André Bombeck c7844c3bc6 fix(safety): screen the dose-change class, not one sentence shape
"Increase your metformin to 2000 mg daily" passed the outbound dose
screen. The English change pattern accepted only "it" or "your dose"
between the verb and "to", so a drug name in that slot walked past it,
and no pattern covered an imperative to take a stated amount, a count
unit (tablets, capsules, puffs), or an imperative to skip or stop a
medication. Each locale bank had the same narrow object slot.

Add a per-locale dose-change class (en, de, fr, es, it, pl, ko) with
three shapes: an imperative or infinitive change verb with up to four
words of object and a target dose; an imperative to take or inject a
stated amount; and an imperative to halve, double, skip, pause or stop
a medication. Negated imperatives, past-tense log restatements,
present-tense descriptions ("you take 1000 mg"), clinician referrals
without a target, and nutrition advice (sodium, water, steps) still pass.

A unit now ends on an explicit lookahead instead of \b, so a lab
concentration ("under 140 mg/dL") no longer trips the dose bank while a
rate ("2000 mg/day") still does. "as usual" and "wie gewohnt" join the
continuation anchors, still gated on the user's schedule.
2026-10-06 20:12:05 +02:00
Marc-André Bombeck d403cf40af Merge reasoning settings, admin controls and background reasoning into release/v1.41.0 2026-10-06 20:07:52 +02:00
Marc-André Bombeck ae5cee331e feat(coach): resolve the turn's thinking depth from the person and the operator
The chat route hands the turn the level resolveCoachTurnReasoningLevel
returns: the stored Coach preference, absent read as medium, after the
operator's switch and cap. The turn still applies the operator-paid
ceiling once its chain is known. The controls loader no longer throws
when the request cache cannot serve it; it fails closed to off. The
admin guard pins the route to the resolver.
2026-10-06 19:57:04 +02:00
Marc-André Bombeck 85ebdad5c9 Merge remote-tracking branch 'origin/v141/integration' into v141/d-settings 2026-10-06 19:54:43 +02:00
Marc-André Bombeck 3f7a16316f chore(ai): drop an unused reasoning default 2026-10-06 19:54:42 +02:00
Marc-André Bombeck 8e7fd0b873 chore(coach): publish the trail route, list it among the mixed reads, and ship its strings in every locale 2026-10-06 19:53:43 +02:00
Marc-André Bombeck 2f5877cf97 Merge loop, budget, trail and clarifications into release/v1.41.0 2026-10-06 19:49:51 +02:00
Marc-André Bombeck de5b315999 feat(coach): give the turn the memory block, its recalled facts and a pending proposal
Build the memory block for the providers the turn sends to, send it on
both paths, keep the facts it recalled in the owner's trail, and offer a
health fact the background found as the answer's one note. Every turn
enqueues the memory refresh; the job's debounce and singleton key decide
when it runs. A remembered fact points back to the stored user message.
2026-10-06 19:48:55 +02:00
Marc-André Bombeck 4dd2a2b6bc feat(insights): show the daily briefing how active plans are going
The briefing prompt carries the server-computed progress line of up to
two active Coach plans, fenced as data because it quotes the person's
own goal words. The model may mention one and may not invent one; the
figures in the lines are admitted by the grounding gate like any other
figure the server handed over. The lines join the snapshot fingerprint
only when there is one, so an account without an active plan keeps its
stored fingerprint, and a failure to build them drops the lines, never
the briefing.
2026-10-06 19:46:44 +02:00
Marc-André Bombeck 656d8b8563 Merge remote-tracking branch 'origin/release/v1.41.0' into v141/d-settings 2026-10-06 19:44:56 +02:00
Marc-André Bombeck fadb2ff6ed feat(insights): let the daily briefing and the period narratives reason
runStatusCompletion and runBriefingCompletion take an optional
reasoningJob. Only a named job carries reasoning, resolved against the
operator's switch and cap and the payer; a call without one, which is
every status card, batch note, derived score and workout line, sends
exactly what it sent before. Thinking is reserved on the day's ledger
with the answer budget, and a reasoning status call falls back to a
90 s ceiling instead of 60 s.

The briefing reasons on its generation and phrasing re-roll, the weekly
and monthly narratives on their first pass; the JSON and grounding
repairs do not. An inventory test freezes which files may name a job.
2026-10-06 19:44:52 +02:00
Marc-André Bombeck df0689ffbe feat(auth): publish the resolved Coach reasoning on the account payload
GET /api/auth/me gains coachReasoning: the level a turn runs at, the
person's own pick, the highest level the settings may offer, whether
reasoning can run at all, whether the lowest option is a real off, and
the layer that decided. Resolved on the server so the web settings and
the native client read the same answer.

The web reads it through useCoachReasoning. The Coach preferences route
names the stored level on its wide event, and a unit test round-trips
the level through the account-settings backup section.
2026-10-06 19:44:52 +02:00
Marc-André Bombeck adb4cd5c40 feat(admin): carry the reasoning switch and cap on the assistant flags
PUT /api/admin/settings/assistant-flags accepts aiReasoningEnabled and
aiReasoningMaxEffort (low, medium, high), writes them field by field
with an audit row like the other switches, and both methods echo them
as reasoning: { enabled, maxEffort }.
2026-10-06 19:44:52 +02:00
Marc-André Bombeck 74e68745c1 feat(ai): resolve the reasoning level against the operator's controls
Add resolveReasoning, the one decision for how hard a call may think:
the operator's switch, then the provider's ability, then the requested
level (the person's Coach preference, or a background job's own level),
then the operator's cap, then a medium ceiling when the operator pays.
The answer names the layer that decided it.

loadReasoningControls reads the two app_settings columns, memoised per
request, read afresh in background runs, and fails closed to "off".
loadCoachReasoningState resolves the published Coach state from the
memoised provider presence; resolveJobReasoning resolves one background
call and never fails it.

A guard proves the switch and the cap over the whole input space and
requires every call that carries reasoning to take it from the
resolver.
2026-10-06 19:44:51 +02:00
Marc-André Bombeck 8f58cad7f3 fix(insights): say a steady usual value as one figure, not a range of equal ends 2026-10-06 19:41:39 +02:00
Marc-André Bombeck f46e0960fb Merge remote-tracking branch 'origin/v141/e-memory' into v141/b-loop 2026-10-06 19:39:10 +02:00
Marc-André Bombeck 88b2bf13b0 Merge remote-tracking branch 'origin/release/v1.41.0' into v141/b-loop 2026-10-06 19:39:06 +02:00
Marc-André Bombeck 8e74bc05c8 test(coach): cover the budget, the brake, the trail, questions and comparisons
Unit tests for the turn budget and the no-progress brake, the budgeted
loop (every stop reason, the round-by-round ledger, repeated calls,
questions, comparisons, reasoning and provider state), the trail recorder
and its screen, clarifications through the tool and the brake, the
comparison table and chart, the trail column and its owner-only read, the
reply frames, and the turn's frames end to end through the route. An
integration test proves the trail against a real database. Eval
scenarios cover a multi-round why question, a period comparison with its
chart, a question asked through the tool, and keep the charts the
existing table scenarios already showed.

Take the effective reasoning level from the route rather than resolving
it in the turn; the turn only caps it when the operator pays.
2026-10-06 19:38:57 +02:00
Marc-André Bombeck 0ba698c54f Merge memory, plans and voice into release/v1.41.0 2026-10-06 19:33:17 +02:00
Marc-André Bombeck 912558b96a feat(coach): answer first, short and without filler
Give the Coach one statement of how an answer is shaped, in the German and
English bodies and on every other locale: the first sentence answers, one
carrying number with its unit and window, the person's own baseline, an
assumption in one line, at most one next step and no habitual closing
question, 40 to 120 words, and a closed list of filler it never writes.

- Brief is 30 to 70 words in one paragraph, detailed up to 220 in three.
- Motivational-interviewing moves only when the person opens a change topic,
  at most one, never as a closing question; the confidence ruler only on an
  explicit plan request.
- The few-shot examples are rewritten to model the voice.
- Result tables: show the numbers whenever they carry the answer and span
  more than one value, a table for exact values or up to twelve like rows,
  one figure alone only for a moment.
- Follow-up chips are the answer's only "what next".
- A voice grader for the eval (length, filler, emoji, next steps, closing
  question, warm-up opening) plugs into the golden set as criteria; five
  voice cases in German and English.
- A guard holds both prompt bodies to the rules and every example answer to
  the grader, each check broken once on purpose.
2026-10-06 19:32:32 +02:00
Marc-André Bombeck acec87f02c feat(coach): give every turn its memory and let the Coach remember and propose plans
What a person told the Coach never reached a tool-mode turn: facts and plans
rode the snapshot's memory block, which tool mode does not send, and the
extraction ran only past twenty turns and failed to parse on every
non-Codex chain, because the status chain runs in JSON-object mode while the
prompt asked for a bare array.

- Memory block: WHAT YOU KNOW ABOUT THIS PERSON, built per turn behind the
  wire egress check (nothing is read on a refusal), fenced as data, at most
  1,500 characters, never trimmed before the inventory. Confirmed facts
  first, then the newest; active plans with a server-computed progress line;
  reminders the person asked for. Medication facts stay out while the
  medications module is off. Building it stamps last_used_at without moving
  updated_at. buildCoachToolRequest and buildCoachProviderPrompts take it as
  an optional memoryBlock; the snapshot memory keeps only the narrative and
  trend recall.
- remember_fact: preferences, goals and context are saved at once
  (source coach, source_message_id set); anything that names a condition,
  limitation or medication, by category or by wording, is only proposed.
  A fact must come from the person's current message; one note per answer.
- The person's answer reads the proposed fact from the encrypted trail on
  the assistant message, never from the request.
- propose_plan writes a proposed plan; the tap activates it with its review
  window counted from the tap. One per answer, three open at most.
- Progress: one sentence per active plan from the person's own day series
  and units, also offered to the briefing (two lines at most, behind the
  briefing capability). The plan review and the progress line read a step
  plan as daily totals instead of per-sample means.
- Background extraction asks for {"facts":[]} / {"plans":[]} and reads
  either shape, runs once a conversation has been quiet for 30 minutes, and
  stores health facts as proposals offered once at the next turn.
- Unanswered plan and fact proposals lapse after 14 days in the reminder
  sweep.
- Memory list API: POST saves one of the caller's own messages (the
  remember button), PATCH edits a fact's wording, GET reports source, origin
  and last use and never lists a waiting proposal.
- A structural guard keeps the egress check ahead of every read and the
  callers of both builders to the turn and the briefing.
2026-10-06 19:32:31 +02:00
Marc-André Bombeck 6112cfc6d5 test(ai): keep provider state out of logs and storage
providerState may be read only in the clients, the contract and the Coach tool loop, never on a line that logs, annotates or persists, and has no schema column.
2026-10-06 19:26:30 +02:00
Marc-André Bombeck 34afa3a4f0 feat(ai): reason deterministically in the mock and count reasoning in the chain runner
The mock reports a fixed summary, title and provider state for calls that ask for reasoning. The runner passes reasoning and onReasoning to every candidate unchanged and adds the reasoning token and summary counts to the wide event, never the text or the state.
2026-10-06 19:26:30 +02:00
Marc-André Bombeck edf6fffc32 test: admit the per-call reasoning level in the reasoning_effort guard
The key now appears twice in each client: the bound chain setting, sent only when the call carries no level, and the call's resolved level. The guard pins both, the condition in front of the bound value, and the absence of a literal.
2026-10-06 19:26:30 +02:00
Marc-André Bombeck 0281883247 feat(ai): send reasoning on chat-completions endpoints and local servers
OpenAI sends reasoning_effort for reasoning models on rounds without tools, off mapped to each family's lowest value. Gateways send reasoning_effort, OpenRouter its reasoning object, and the reasoning details or thinking blocks a gateway returns go back on the next round's assistant message. Local servers send reasoning_effort and read reasoning_content, reasoning and a leading think block, live on the stream without letting it reach the answer.

The thinking budget rides on top of the answer budget where reasoning counts against it. A refused reasoning field is retried once without it and remembered per endpoint and model, before the JSON dialect check. A call's own level wins over the stored chain setting, which still applies when the call has none.
2026-10-06 19:26:29 +02:00
Marc-André Bombeck 6b1bafca3c feat(ai): ask Anthropic models to think in the dialect their generation takes
Up to 4.5 a thinking budget (with the interleaved-thinking header on tool rounds), from 4.6 adaptive thinking with an effort and summarized display. From the 5 generation thinking cannot be switched off, so off becomes the lowest effort. Temperature is never sent from 4.7 on, nor with thinking, which also removes a 400 on the newer model presets.

Thinking blocks travel to the next round unchanged, signature included; a tool round another provider started runs without thinking. Every text block is joined instead of only the first. The JSON instruction goes only to callers that ask for JSON. Unknown model names learn the dialect and the sampling rule from a refusal.
2026-10-06 19:26:29 +02:00
Marc-André Bombeck d87b8204f3 feat(ai): send reasoning on the Codex wire and hand its items back between tool rounds
A call that carries a reasoning level sends reasoning {effort, summary} with include [reasoning.encrypted_content]; off is effort none. Summary titles are reported as soon as the bold first line closes, the text when the part is done. Reasoning items come back as provider state and are re-inserted before the next round's message and calls, only for the slug that made them.

A 400 that names a reasoning parameter is answered with the lesser request the backend lists (no summary, the nearest supported effort, no reasoning), remembered per slug. Calls without a level keep reasoning null and include [].
2026-10-06 19:26:28 +02:00
Marc-André Bombeck 8d23c79ef5 feat(ai): add the reasoning support matrix and a per-endpoint dialect cache
reasoningSupport() reads what each provider can do with reasoning from the provider type and model name: effort, summaries, live summaries, state that must travel between tool rounds, and whether off is a real off. Unknown names are treated optimistically.

The dialect cache records what an endpoint refused (keyed by provider, endpoint and model) so a client retries with the lesser request once and never pays the refused round-trip again. Downgrades are marked on the wide event with meta keys only.
2026-10-06 19:26:28 +02:00
Marc-André Bombeck cf68055cb2 feat(coach): run the turn on a budget with a live trail, questions and comparisons
Replace the fixed three tool rounds with a budget fixed by who pays:
120k tokens, 150 s and 12 rounds on the person's own plan, key or local
model, 40k, 90 s and 6 rounds when the operator pays. Cached input counts
at a tenth. The final answer is reserved from the start, so a turn whose
budget, clock or round cap runs out answers from what it read. A
no-progress brake stops a turn that repeats calls or reads nothing new,
and a repeated call is never run again.

The day's ledger is booked round by round: round one and the final
reserve when the turn starts, each further round right before it runs,
every round settled as it returns, the unused reserve given back at the
end. A round the daily ceiling refuses ends the turn with an answer.

Every phase goes out live as an activity frame (thinking, fetching,
summarising, asking, remembering, planning, the stop, the answer), with
screened reasoning titles and summaries; the structure rides the
provenance and the text is sealed in trail_encrypted, served by
GET /api/insights/chat/{id}/messages/{messageId}/trail. Tables go out as
interim results as soon as they are read.

Add ask_clarification with a server brake, remember_fact and
propose_plan over the memory contract, model-free decision turns,
compare_series with the compare chart, eight tables per message,
assumptions with change-assumption chips, a reasoning level per turn and
at most two concurrent turns per person.
2026-10-06 19:09:15 +02:00
Marc-André Bombeck 210becb28b feat(coach): lay down the reasoning, trail and memory contract
Add the shared contract the reasoning release builds on, with no change
to how a turn runs today.

- Schema: operator reasoning controls on app_settings (0376), the
  encrypted trail text on coach_messages (0377), and source,
  last_used_at and source_message_id on coach_facts (0378).
- Provider contract: CompletionParams.reasoning and onReasoning,
  CompletionResult.reasoning and providerState, AiMessage.providerState.
- Reasoning levels, thinking budgets and the background-job table in
  src/lib/ai/reasoning/levels.ts; coachPrefsJson.reasoning, absent
  meaning medium, salvaged field by field like every other pref.
- Stream: activity, memoryNote and planProposal frames, done.stop and
  done.withheldResults, result.interim, a compare chart kind, eight
  table refs, three more clarification kinds with an assumed choice,
  the change_assumption chip, and assumptions, activity, stop,
  memoryNote and planProposal on the provenance. Zod mirror, type
  parity and the native-client key rule cover every new frame.
- Chat request: memoryDecision and planDecision, parsed and not yet
  acted on.
- Stub contracts for the memory functions and the activity recorder,
  inert until their implementations land.
- Backup, restore and rotation carry the new columns; a fact's message
  reference is resolved against the restored messages like its
  conversation reference.
- Message keys for the trail, memory, plans, assumptions, replies,
  interim tables, comparison charts and reasoning settings in all seven
  locales, each reachable from a catalog and checked both ways.
2026-10-06 18:39:50 +02:00
Marc-André Bombeck f269100b01 Merge pull request #1171 from MBombeck/release/v1.40.6
chore(release): v1.40.6
2026-10-06 00:51:13 +02:00
Marc-André Bombeck ef9dc1ddc6 test: pin the Coach layout clock to midday and read copied text without a tag-stripping replace
The panel groups conversations by the browser's local day, so the e2e
fixture's "an hour ago" landed in yesterday when CI ran just after
midnight. The turn-steps test reads its markup by splitting rather than
with a replace CodeQL reads as an incomplete sanitiser.
2026-10-06 00:30:13 +02:00
Marc-André Bombeck 68720418e3 docs(changelog): add the MCP schema dialect and the local-day briefing to 1.40.6 2026-10-05 22:34:13 +02:00
Marc-André Bombeck 5328c6a534 Merge branch 'fix/mcp-schema-2020-12' into release/v1.40.6 2026-10-05 22:33:59 +02:00
Marc-André Bombeck 6671276d57 Merge branch 'fix/v1406-today' into release/v1.40.6 2026-10-05 22:33:59 +02:00
Marc-André Bombeck 63b395915e test: seed cached briefings with their generation moment
The today check now reads briefingGeneratedAt from the cached payload
and treats a payload without it as not from today. The integration
and e2e fixtures that seed a fresh briefing write it, as every real
generation does.
2026-10-05 22:28:10 +02:00
Marc-André Bombeck 7c7576423f fix(dashboard): give the score's meta lines their own row on a phone
Below md the lines under the 80 px dial (the score delta, how long it
has held, what it rests on) were capped at the dial's width. In German
they broke into three to five rows each, and beside a short lead that
column set the height of the read row and left a gap above the facts.

The score column now dissolves into the grid below md: the dial stays
the top-right cell beside the lead, and its meta lines take a
full-width row right after the read row, flush with the dial's edge,
one line each. The facts move one row down. From md up the column is
the same flex column as before (desktop captures are pixel-identical).

The Today overview e2e renders German at 390 and 360 px with a short
and a long lead and asserts the dial size, one row per meta line, the
lines flush with the dial, the meta row right after the read row, and
exactly one row gap above the facts.
2026-10-05 22:21:07 +02:00
Marc-André Bombeck 43361bd74e fix(daily): write the briefing for the reader's own morning and readings
With signals of the day resting on today's readings only, the briefing
had to be written after those readings, and it was only written at the
04:30 Berlin warm, the post-sleep morning refresh, or a page open on a
day-old cache. A morning blood pressure or weigh-in never became a
signal, and since an earlier day's briefing is no longer served, a
reader in Los Angeles or Seoul had none for most of the day: the Berlin
warm lands the previous evening there, or at noon.

The snapshot read every surface goes through (dashboard, iOS, digest,
morning push) now asks for a briefing for today at two moments: the
first read of the reader's local day with no briefing written today,
and the first read after a signal metric's first reading of the day
that the briefing has not read. Each reason is claimed once per user
per local day in a Postgres bucket and remembered in-process, so later
reads cost no query; no write path needs a hook.

A claim enqueues one today warm: immediately for a new day, after a
five-minute window for readings, so a burst (weigh-in, then blood
pressure) folds into one job. The warm writes the comprehensive
briefing only, past the one-hour freshness gate but under the failure
backoff, the daily forced-warm cap (two per 24 h) and the content-hash
gate.

Worst case per user per local day: one enqueue per reason, at most
eight (the day plus seven signal reading types, measured over a day of
reads every two minutes); at most two forced comprehensive attempts
under the existing cap, each at most three completions (the answer, a
JSON retry, a grounding retry), so six provider calls at the ceiling.
A warm whose data has not changed costs at most the existing
once-a-day paragraph re-roll, inside the same cap.
2026-10-05 22:09:41 +02:00
Marc-André Bombeck 79e0eca023 fix(mcp): advertise tool schemas as JSON Schema 2020-12
The SDK renders every tool's Zod shape with a fixed draft-07 target and
stamps "$schema": "http://json-schema.org/draft-07/schema#" on each
inputSchema and outputSchema. Clients that validate with a 2020-12-only
validator refuse that dialect when the tool list arrives, so every tool
was unusable there (#1170).

Wrap the tools/list handler once, after the last tool is registered, and
re-render each advertised schema from the same Zod shape with the
draft-2020-12 target, without a $schema key. A schema with no $schema is
2020-12 under the MCP spec and still compiles in a draft-07 client. All
other fields of a tool entry pass through unchanged, and the SDK keeps
validating structuredContent against the Zod shape, so tool results are
unaffected. For the current surface the 54 rendered schemas differ from
the previous ones only by the dropped key.

The new guard reads tools/list through a real client for a write-scoped
session, rejects any draft-07 marker or construct, and compiles every
schema with Ajv's 2020-12 build (ajv added as a dev dependency at the
version the SDK already resolves).
2026-10-05 22:09:03 +02:00
Marc-André Bombeck 143a39e369 fix(coach): take a condensed series summary from its daily values
The summary of a condensed block was taken over the block's mix of daily
values and weekly means: over a year the newest fourteen days weighed as
much as fourteen weeks, min and max were extremes of means, and change was
one day minus a weekly mean.

Each series block now hands the budget pass its daily values over the
whole read, computed only when the block is condensed. The summary states
the first and last day, the lowest and highest day, the mean of the days,
and the change from the first calendar week to the latest one. A block
without daily values gets no summary rather than one of what is left. The
coverage note says how the summary is taken.
2026-10-05 22:03:59 +02:00
Marc-André Bombeck f6b0131731 fix(daily): serve the briefing as it stands today on every surface
Only the digest applied briefingForToday. The dashboard snapshot (the
web dashboard, its server prefetch and the iOS cold-launch read) and
the /insights advisor payload still served an earlier day's text and
the raw model deltas ("+33.72 bpm").

The snapshot read now resolves the briefing after the cache, beside
the capability decision, from the payload's generation moment and the
snapshot's own newest reading per type; a withheld briefing reads
"preparing", never "ready" with nothing in it. The digest takes the
resolved briefing from that read on its own clock instead of resolving
again. The advisor route resolves dailyBriefing on every branch it
returns, reading the newest reading per signal type in one grouped
query only when a reading-backed signal is present; text generated on
an earlier day no longer counts as fresh on the read GET (so the warm
is enqueued) and no longer short-circuits a provider-backed POST.

The OpenAPI descriptions of both advisor responses say so.
2026-10-05 22:03:58 +02:00
Marc-André Bombeck 491d542f58 fix(daily): judge the briefing's day by when its text was written
The check that keeps an earlier day's briefing off the Today surfaces
read the cache row's insightsCachedAt. Every warm moves that column,
including the unchanged-hash short-circuit that keeps yesterday's text
and only refreshes the stamp, so text written the day before passed as
today's after the nightly warm.

The cached payload now carries briefingGeneratedAt, written only where
new text is written: a full generation in the warm and in the
on-demand route, and the daily paragraph re-roll. The stamp refresh
leaves it untouched. briefingForToday reads that moment, and a payload
without it counts as not from today, so the hero falls back to its own
lead until the next generation.
2026-10-05 21:57:49 +02:00
Marc-André Bombeck 756c3fb845 fix(coach): condense each series block in its own fields
The condensing step read every block as a value series: daily rows under
value, weekly rows under mean and count. Sleep nights carry minutes, so a
condensed sleep summary ended on the last weekly mean weeks before the
newest night; adherence rates fold by doses, not readings; glucose keeps a
series per measurement context; workouts are a sessions list.

Condensing now knows each block's shape: sleep reads minutes, adherence
folds rate weighted by total doses, glucose condenses every context, and a
workouts block keeps its newest sessions beside the whole-window rollup.
2026-10-05 21:54:46 +02:00
Marc-André Bombeck 14a2931a99 fix(daily): read signal deltas in the reader's number format
formatSignalDelta matched only the locale's decimal mark, so an
English-formatted figure inside German text was cut at the comma
("+1,234.5 kg" read "+1,2.5 kg"), and a difference that rounded to
nothing lost its sign ("+0.04 kg" read "0.0 kg").

A figure with a decimal or grouping mark is now read as a whole: with
a point and a comma the later one is the decimal, thin spaces and
repeated marks are grouping, and a single mark is grouping only when
it is the locale's grouping mark before exactly three digits. The
figure is re-rounded to the metric's precision and re-written with the
locale's own marks. A non-zero difference that rounds to nothing reads
"±0" at that precision. Plain integers stay as written.

Glucose joins the reading types, its precision following the unit the
delta is written in (whole mg/dL, one decimal of mmol/L). The map is
keyed by string, because the published sourceMetric enum has no
glucose value yet and widening it would change the iOS contract.

Tests cover all seven locales.
2026-10-05 21:54:01 +02:00
Marc-André Bombeck 73ce721ff2 fix(mcp): keep the metric series read as it was
The Coach's figure check ran before the MCP branch, so an MCP client was
told retrieval_failed for a block it used to receive, and the plain-text
renderer then said no data was on file. Any single-source build also
condensed its own block, which reached MCP through the source snapshot's
fallback to the full builder.

The figure check now runs after the MCP branch, and condensing is an
explicit option of the snapshot build that only the Coach tools set (it is
part of the cache key). MCP output is what it was before.
2026-10-05 21:52:48 +02:00
Marc-André Bombeck af79f4fb5c fix(coach): do not count the freshness stamp as a figure in a series block
A block trimmed to its unit and freshness stamp passed the figure check,
because asOf.daysAgo is a number. The check now skips the asOf and scope
fields, so that shape answers retrieval_failed like any other block
without a reading.
2026-10-05 21:50:48 +02:00
Marc-André Bombeck 28517affd0 chore(release): v1.40.6 2026-10-05 21:38:46 +02:00
Marc-André Bombeck ab14747013 Merge branch 'fix/dashboard-hero-mobile' into release/v1.40.6 2026-10-05 21:38:21 +02:00
Marc-André Bombeck 861d1ba868 Merge branch 'fix/coach-panel-search-composer-ring' into release/v1.40.6 2026-10-05 21:38:14 +02:00
Marc-André Bombeck ff0769f045 fix(dashboard): lay the Today hero out for a phone
Below md the score ring sat centred under the Today facts as a 168 px
dial, where the bottom navigation covered it. The hero is now one
grid with two arrangements: on a phone the ring is a compact 80 px
dial at the top right beside the lead and signal line, flush with the
card's trailing edge, and the Today facts run the full card width
under both; from md up the ring spans both rows of the trailing
column exactly as before (desktop captures are pixel-identical).
Without a lead the facts take the leading cell, so the ring never
sits beside an empty row.

ScoreRing gains a `responsive` size (80 px, md:168 px) whose label is
left out below md; the ring keeps its aria-label and the surrounding
link names it. The meta lines under the phone dial are capped to its
width, so the lead keeps at least 180 px beside it at 360 px.

Each fact now stacks its muted label above its foreground value on
the reading edge at every width, instead of a label-left /
value-right row that broke long values into a ragged right-aligned
block. Rows stay at the 44 px tap floor below md.

The Today overview e2e covers 1440, 390 and 360 px in both themes
for an AI lead, a long AI lead, an AI-off day and a quiet day, and
asserts the dial size, its place beside the lead, symmetric insets,
full-width facts and stacked 44 px rows on a phone.
2026-10-05 21:30:29 +02:00
Marc-André Bombeck ac48e0d0ed fix(coach): give a series read its numbers when the shared build cut its block
Inside a chat turn every tool read lands on the turn's shared full-source
snapshot when its window matches. On an account with many synced series
that build passes the prompt budget, and the budget pass collapses blocks
to a bare unit and then to an `{ omitted }` marker. get_metric_series
sliced the marker out and answered `present: true` with it, so the model
was told the readings exist and was given no figure. The same held for
sleep, glucose, compliance and workouts.

- The snapshot result names the blocks the budget pass cut. A tool whose
  block was cut in the shared build reads it again from its own source at
  the same window.
- A read scoped to one source condenses its own blocks last and never
  swaps them for the marker: it keeps a summary (first, last, min, max,
  mean, change) taken before anything is cut, the newest daily values,
  weekly means folded into monthly means, and names each cut.
- get_metric_series never answers present with a block holding no figure;
  that read reports retrieval_failed instead.
- A chat series read carries the readings it counted, so the step shows
  them, and on a long or condensed window a note on what it covers. The
  tool description and a new tool-mode rule tell the model to call
  get_metric_table itself for longer history instead of asking.
- The screen-reader comma between step parts is unselectable, so a copied
  row no longer reads "Weight · ,all time".

MCP output is unchanged: the source-snapshot equality suite stays green.
2026-10-05 21:29:04 +02:00
Marc-André Bombeck aa222f5b02 fix(daily): speak about today only from today's readings
A statement that says "today", or compares today against a baseline,
now only rests on a reading taken on the current calendar day in the
reader's zone. Yesterday morning's pulse read at nine in the evening
was narrated as today's: the signals of the day accepted any reading
from the last two weeks, and the Today overview, the coincident
deviation flag and the health status counted yesterday as current.

- computeSignalsOfDay drops a metric with no reading today, so the
  briefing prompt and its content hash never see a stale value as the
  day's; the prompt says so in both languages.
- The digest serves no cached briefing written on an earlier day and
  drops a reading-backed signal whose metric was not measured today,
  so the web hero, the iOS topSignal and the morning push agree.
- The Today overview's vitals line and deterministic lead, the
  coincident flag and the health status deviations use the new
  isFromToday rule (calendar day, not elapsed hours).

Signal deltas now carry each metric's display precision: the prompt
input is rounded (whole bpm and mmHg, one decimal of kg), and the
delta string under the hero lead is re-read at that precision in the
reader's locale ("+34 bpm", not "+33,72 bpm").
2026-10-05 21:22:57 +02:00
Marc-André Bombeck bed47387de fix(ui): show text-field focus as a neutral border, not a purple ring
Every text-entry control (Input, Textarea, Select and NativeSelect
triggers, the date and time fields, the calendar's dropdowns, the Coach
composer and the hand-built note fields) now marks focus by turning its
border the new neutral input-focus tone, about 5:1 on the dark
background and 4:1 on the light one, with no ring. A text field matches
:focus-visible on every mouse click, so the ring was on for everyone.
An invalid field keeps its red border and draws its destructive ring
only while focused. Buttons, links, checkboxes and switches keep their
rings.

text-field-focus-guard.test.ts fails when a text-field class string
brings a focus ring or a primary/ring-coloured focus border back.
2026-10-05 21:08:48 +02:00
Marc-André Bombeck b6ce54e2c6 fix(coach): drop the search field from the conversations panel
The panel lists the history grouped by recency with load-more, rename
and delete; title search stays on the full /coach/conversations page.
2026-10-05 21:05:13 +02:00
Marc-André Bombeck 6cea4be2a1 Merge pull request #1169 from MBombeck/feat/coach-panel-full-height
chore(release): v1.40.5
2026-10-05 20:05:49 +02:00
Marc-André Bombeck 315b653079 fix(coach): keep the docked panel header and new-chat button clear of the safe areas
A standalone PWA on a tablet reserves the status bar in the top bar but
not in the docked panel's header row, which could tuck its title and
icons under the status bar; the new-chat button sat without the home
indicator inset. Both now reserve the same insets as the top bar.
2026-10-05 19:47:50 +02:00
Marc-André Bombeck b01c193274 chore(release): v1.40.5 2026-10-05 18:46:44 +02:00
Marc-André Bombeck 61f065c651 feat(coach): run the conversations panel the full height of the window
The docked panel was part of the page, below the top bar. It is now a
column of the shell beside the content column, portalled through a new
side-panel slot: it runs from the top of the window to the bottom, flush
right, and the top bar ends at its left edge. Shut, the panel is gone
entirely and the top bar runs to the window's edge. The panel's header row
is the top bar's band, so the two bottom borders draw one line; the panel
takes the sidebar surface and border, like the rail on the left.

The top bar says where the reader is on desktop: "Coach", then the open
conversation's title, truncated. The panel toggle is its last item, 12 px
from its trailing edge, so it stands against the open panel and at the
window's edge when the panel is shut; it carries a tooltip and is 28 px
beside a fine pointer, 44 px with touch.

New chat is a round 48 px primary button with a plus, 24 px in from the
panel's corner (16 px in the phone sheet), with a tooltip. The top bar's
action slot and the new context and side-panel slots share one portal
helper.
2026-10-05 18:46:27 +02:00
Marc-André Bombeck 4909a94485 Merge pull request #1168 from MBombeck/feat/coach-panel-polish
chore(release): v1.40.4
2026-10-05 18:23:00 +02:00
Marc-André Bombeck 4ec9b58415 chore(release): v1.40.4 2026-10-05 17:53:27 +02:00
Marc-André Bombeck 3c59fc168e test(coach): document that a hidden stored view chip still resolves
Reading a stored reply back drops a view chip whose table the answer
already shows with its toggle, but the resolver reads the raw stored
chips. A tap from a client that still shows such a chip is answered, by
design: the chip was a valid offer when it was stored. The test pins both
halves for "as a table" and "as a chart".
2026-10-05 17:53:18 +02:00
Marc-André Bombeck 7533a3c5de test(shell): pin the full-bleed condition to exactly /coach
The condition moves out of the shell into isFullBleedPage(), a pure
helper with its own test: /coach renders edge to edge; /coach/plans,
/coach/conversations, a trailing slash, look-alike paths and other
routes keep the padded frame, and so does a refusal on /coach (outside a
shared record, module off).
2026-10-05 17:52:45 +02:00
Marc-André Bombeck b2c188bc46 fix(coach): keep the answer details open on Enter and Space
Focus opens the details tooltip, and the click that Enter or Space then
fires toggled it shut again. A click with no pointer-down before it is a
keyboard activation and now always opens; a tap or a mouse click still
toggles, and Escape closes it.
2026-10-05 17:52:14 +02:00
Marc-André Bombeck 11b6119482 refactor(shell): return the switched-off module instead of a flag
The flag helper sat between the module gate's doc comment and the
component, so the comment read as its own. It is now
switchedOffModule(), which returns the owning module when it is off and
has a doc comment of its own above the gate's; the gate narrows on its
result directly instead of looking the module up twice.
2026-10-05 17:51:32 +02:00
Marc-André Bombeck c133b4ac74 fix(coach): size the panel's controls by pointer, not viewport width
The panel header buttons shrank to 28 px from md and the conversation rows
to 36 px from sm, so a touch tablet wide enough to dock the panel got
pointer-sized targets. They now shrink only under pointer-fine: 28 px
header buttons, a 36 px gear in the drawer, 36 px rows and row menus
beside a mouse or trackpad, the 44 px floor with touch at any width.

A unit test refuses a width breakpoint on a control size in the three
panel files; an e2e case measures the docked panel at 1280 px with touch
and with a fine pointer.
2026-10-05 17:50:55 +02:00
Marc-André Bombeck 5aa3f3346d chore(release): v1.40.4 2026-10-05 17:46:26 +02:00
Marc-André Bombeck b0ac267af9 fix(coach): stop offering a view chip the result panel already toggles
A table the answer displays with a chart renders its own chart/table
toggle, and the "Show as a chart" / "Show as a table" chips under it
repeated that control. The server no longer derives a view chip for such
a table; a table the answer only used, folded under "Data used", still
gets "Show as a chart", since that brings it into the answer. A reuse
turn shows its table with the toggle and so offers no view chip either.

The rule is one predicate in a leaf module, applied where chips are
derived and where a stored reply is read back, so a reply stored before
the change offers the same chips as a new one on every client.
2026-10-05 17:41:20 +02:00
Marc-André Bombeck 0c3de5bdfb feat(coach): move an answer's model and tokens behind a details icon
The tokens and model no longer sit as right-aligned text under a Coach
answer. The row is one left-aligned group: copy, read aloud, try again, a
details icon, then the time. The details icon is a focusable button whose
tooltip names the model and the token count; it opens on hover, on
keyboard focus and on a tap, and a second tap, a tap elsewhere or Escape
closes it. Strings in all seven locales; the joined tokens-with-model
string is gone.
2026-10-05 17:29:28 +02:00
Marc-André Bombeck 3bede8230b fix(coach): mirror the panel toggle glyph and keep it beside the open panel
The toggle shows the panel-right open glyph while the panel is open and
the close glyph while it is shut, both mirrored so the chevron points the
way the panel moves on a click. Docked and open, the toggle keeps the
panel's width as its right margin, so it sits beside the panel's leading
edge and travels with it (200 ms, none under reduced motion). Labels and
aria-expanded are unchanged.
2026-10-05 17:25:24 +02:00
Marc-André Bombeck c49a04bac6 fix(coach): dock the conversations panel flush against the right edge
The Coach page cancelled the shell's container padding with negative
margins, so <main> kept its reserved scrollbar gutter and painted a dead
strip to the right of the panel on classic-scrollbar platforms. The shell
now renders /coach full bleed: no centred container, no padding and no
reserved gutter, and the page fills what <main> leaves below the top bar.
A refusal card (shared record, module off) keeps the padded frame.

The panel header takes one 12 px inset with a gap of 8 between title and
icons, a text-lg title, and 28 px icon buttons beside a pointer (44 px on
touch). Conversation rows take the control radius and a 36 px pointer
floor.
2026-10-05 17:24:49 +02:00
Marc-André Bombeck 854cd31e18 Merge pull request #1167 from MBombeck/fix/v1403-polish
chore(release): v1.40.3
2026-10-05 13:37:36 +02:00
Marc-André Bombeck 9158cf3e24 chore(release): v1.40.3 2026-10-05 13:12:18 +02:00
Marc-André Bombeck 22cb792379 fix(insights): read a same-hours day by its day mean
The same-hours standing averaged each day as sum / n. Pulse already folds
its day as the mean of its local hours' means, and the day-aggregate reader
returns that as dayMean; a type moved onto that reader would have been
compared raw. The standing now reads dayMean where the reader carries one.
2026-10-05 13:12:06 +02:00
Marc-André Bombeck abb1c9f8a9 fix(daily): count the ellipsis toward the lead budget
Text with no space inside the budget was cut at 160 characters and then
given an ellipsis, so the lead came out 161 long. The ellipsis now counts
toward the budget: a space at the edge still ends a whole word, and
unbroken text is cut one short.
2026-10-05 13:11:29 +02:00
Marc-André Bombeck 2a3bd67b68 fix(insights): cut glucose days at today's latest reading and say so
The same-hours comparison cut the earlier days at the current clock time.
At two in the afternoon a day holding only its fasting reading was then
held against mornings plus lunch, and read as below its range again. The
earlier days are now cut at the local time of today's latest reading.

A range read this way is the usual range for this time of day, not the
whole-day band other surfaces show. The deviation carries a basis, the
health-status API publishes it as `basis` (`day` or `sameHours`), and the
Today lead and the health-status card name the range as the one for this
time of day, in all seven locales.
2026-10-05 13:10:42 +02:00
Marc-André Bombeck 130ace74ee fix(crypto): keep updatedAt when key rotation re-seals a value
Key rotation rewrote every ciphertext column through a plain Prisma update,
in the in-app corpus walk and in the rotation script alike, so a rotation
stamped the run time on every encrypted row: the Coach panel then listed
old conversations as recent, and every client re-pulled measurements and
mood entries whose sync cursor had moved. Both now read each row's
updatedAt and write it back, matching on it, so a row edited since the
read is left for the next run instead of being stamped. The models that
carry the column are listed beside the registry and checked against the
schema, and the updatedAt guard now covers the rotation writes.
2026-10-05 13:07:22 +02:00
Marc-André Bombeck 7400852fb4 fix(daily): match a lead's metric by whole words
The terms that decide whether the Today lead already names the top
signal's metric were bare stems: "risen" and "chosen" matched the Polish
"sen" (sleep), "impulse" matched "puls", "Passing" matched the Italian
"passi", and the Korean "잠" matched "잠시" (a moment). A lead about
weight then dropped a sleep headline. Every term now has to start a word,
whole-word terms have to end one (Unicode-aware on both sides), and the
Korean terms carry their particle.
2026-10-05 13:02:04 +02:00
Marc-André Bombeck 25c34b0e7c chore(release): v1.40.3 2026-10-05 12:53:40 +02:00
Marc-André Bombeck 7836610358 fix(coach): move bumped conversation timestamps back to the last message
Earlier rewrites of conversation rows stamped updated_at with the run time,
so the Coach panel listed untouched conversations under Today. Migration
0375 moves updated_at back to the newest message's created_at where it is
more than a minute later. A rename or attachment shortly after the last
turn keeps its stamp, conversations without messages are left alone, and a
second run changes nothing. One set-based statement; the per-conversation
maximum reads from the (conversation_id, created_at) index.
2026-10-05 12:53:40 +02:00
Marc-André Bombeck 370fa10efa docs: describe the Today overview in the README hero alt text 2026-10-05 12:40:55 +02:00
Marc-André Bombeck a6d841d3ae fix(insights): compare a glucose day in progress with the same hours
Today's glucose mean was held against a band built from whole days. By
mid-morning the day holds a fasting reading and nothing after, so an
ordinary morning read as "below your range". A glucose day still in
progress is now compared with the earlier days cut at the same local clock
time, read in SQL so a dense stream is averaged whole rather than through
its newest fifty rows. With fewer than seven earlier days that have
readings in those hours, today gets no verdict. The day-aggregate reader
gains the local-clock cut; an integration case pins it against the
in-memory fold in a half-hour zone.
2026-10-05 12:40:55 +02:00
Marc-André Bombeck 7d42cf7122 fix(coach): keep updatedAt through storage-only rewrites
The title encryption backfill moved each Coach conversation's title into
ciphertext with a plain Prisma update, which stamps updatedAt. The Coach
panel orders and groups conversations by that column, so threads untouched
for months showed up under Today. The memory summary refresh did the same
in the background.

Storage-only rewrites now carry the row's own updatedAt and match on it, so
a turn that lands meanwhile wins and the row is retried on the next pass:
conversation titles, practitioner contacts, measurement and mood notes
(where updatedAt is the sync cursor), inventory notes, and the mood-note
script. The summary refresh writes through raw SQL. A guard freezes both
rules: every backfill write on a model with @updatedAt names the column or
is listed with the reason the stamp is wanted, and every conversation
write outside the person's own actions keeps it.
2026-10-05 12:40:55 +02:00
Marc-André Bombeck 3381026acb fix(insights): give a personal band a minimum width
When most days in the window carry the same value, the median ± k·MAD band
has no width: the coach strip read "Your usual range is 61–61 bpm" and the
next ordinary reading of 62 counted as outside it. The shared band builder
now keeps the edges at least the measurement's own day-to-day wobble apart
(per metric, in its canonical unit, falling back to one display step), so
every surface built on it reads a real range and classifies against it.
The reported spread stays the measured dispersion.
2026-10-05 12:40:55 +02:00
Marc-André Bombeck f5b63d33ab fix(charts): name every series by its localised metric label
The chart's label map covered eleven measurement types and fell back to
the raw enum for the rest, so a resting-pulse chart with its trend
overlays on showed "RESTING_HEART_RATE" in the trend line, the legend,
the tooltip and the spoken summary. Types outside the chart's short
labels now take their measurement label, which exists for every type in
every locale.
2026-10-05 12:40:55 +02:00
Marc-André Bombeck 524975d5f3 fix(daily): decide the line under the Today lead on the server
The muted line under the lead repeated what the lead had just said: a lead
about blood pressure sat above "Your latest blood pressure is sitting in
the optimal band · ↓ ~10 mmHg…". The web client only dropped the headline
when the lead quoted it word for word, and other clients had no rule at
all. The digest now carries `signalLine`, built next to the lead: when the
lead already talks about the top signal's metric (matched in every shipped
language) or says its headline, only the delta remains, and with no delta
there is no line. The hero renders the field verbatim.
2026-10-05 12:19:41 +02:00
Marc-André Bombeck 20085d5759 fix(daily): show the Today lead whole instead of cutting it mid-word
A lead past the 160-character budget was sliced at a character count, so
a long briefing sentence reached the hero as "…sleep landed r…". Model
text now leads only when it fits whole: the reaction line keeps the
complete sentences that fit, the briefing leads with its first sentence
when it fits, and otherwise the headline or the deterministic sentence
takes the slot. Only when no source fits is the first sentence shortened,
at a word boundary. The budget is unchanged, so the hero keeps its
height.
2026-10-05 12:16:40 +02:00
Marc-André Bombeck a9bddca998 Merge pull request #1166 from MBombeck/docs/readme-shots-1402
docs: refresh README screenshots from the 1.40.2 demo
2026-10-05 12:10:53 +02:00
Marc-André Bombeck 7306bcda5c Merge pull request #1165 from MBombeck/feat/demo-seed-v1402
fix(demo): seed the surfaces the start page and Coach now show
2026-10-05 12:05:51 +02:00
Marc-André Bombeck 021c8c0adc docs: refresh README screenshots from the 1.40.2 demo
Recapture the dashboard, Insights, resting heart rate, Medications and
Coach after a fresh demo seed. The dashboard image now shows the Today
overview and the worth-a-look rail; the Coach image shows the history
grouped by day and a full answer with its steps and action row. Frames
match the previous refresh (2552x1308; the dashboard keeps 2434x1247).
2026-10-05 11:43:44 +02:00
Marc-André Bombeck adb0e7c3b1 fix(demo): seal the Coach titles the way the app writes them
A readable title is moved to title_encrypted by the boot backfill, and
that update also sets updated_at to the moment it ran, so every seeded
thread sorted under "Today" a minute after the restart. The seed now
writes the ciphertext directly.
2026-10-05 11:39:15 +02:00
Marc-André Bombeck 25d486f956 fix(demo): end glucose at yesterday and steady the sleep average
The vitals check holds a day's glucose mean against the band of full
days, so a part day seeded up to the current hour read "below your
range". Spot readings and the CGM stream now end with yesterday.

The aggregate sleep walk drifted to almost nine hours, against which the
~7.5 h per-stage nights read as an hour short. A tighter walk keeps the
trailing average near the nights it is compared with.
2026-10-05 11:28:40 +02:00
Marc-André Bombeck 2b5d22e93d fix(demo): seed the surfaces the start page and Coach now show
The demo seed predates the Today overview, the grouped Coach history and
the procedure filter on documents, and it carried a few defects that a
fresh seed made visible:

- Store every Date as UTC. node-postgres serialises a Date in the host's
  zone and the cast to timestamp drops the offset, so a seed run from a
  machine on Berlin time wrote every instant two hours ahead.
- Keep today's later glucose readings, CGM samples and the evening mood
  entry out of the future.
- Close the seasonal allergy and its flare in the spring. Any open
  episode puts the account in Rest Mode, and the open one read
  "Rest mode, day 401" on the start page.
- Pin today's vitals to the median of the prior four weeks, so the
  Today overview does not report a random tail value as out of range.
- Confirm the key-backup step for the key the demo runs with, which
  removes the backup banner every visitor saw above the dashboard.
- Add two practitioners, a knee arthroscopy with its operative report
  and discharge letter linked (small generated PDFs), its follow-up and
  a check-up booked for tomorrow.
- Replace the single Coach exchange with five conversations from today
  back over a few weeks, the newest with a full answer and its steps.
- Drop the layout field `heroVisible`, which the layout type no longer
  has.
2026-10-05 11:23:28 +02:00
467 changed files with 44808 additions and 4523 deletions

No files matched your search

+234
View File
@@ -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
+1 -1
View File
@@ -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"
+1367 -28
View File
File diff suppressed because it is too large. Load diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 235 KiB

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 100 KiB

+524
View File
@@ -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);
});
}
}
});
+72 -26
View File
@@ -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,
);
+267
View File
@@ -0,0 +1,267 @@
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
import {
FIXED_NOW,
done,
end,
feed,
installLiveCoachStream,
posts,
serveConversation,
tokens,
applyTheme,
type StubConversation,
} from "./setup/coach-live-stream";
/**
* A clarifying question is a conversation, not a form.
*
* 1. The question is a normal Coach message; its choices are quiet reply
* pills under it, the assumed one first. There is no card.
* 2. A pill sends its label as the person's own message, with
* `clarification: { messageId, choiceId }`.
* 3. A typed answer works too, with `clarification: { messageId }`.
* 4. The trail above the question stays closed.
*/
const CONVERSATION_ID = "coach-clarify-e2e";
const QUESTION = "Is my heart rate okay?";
const ASKS =
"Do you mean your resting heart rate or your heart rate while walking? Otherwise I'll look at your resting heart rate.";
const ANSWER =
"Your walking heart rate sat around 98 bpm over the last 30 days, close to your usual level.";
const CLARIFICATION = {
kind: "metric",
freeText: true,
assumption: "c1",
choices: [
{
id: "c1",
labelKey: "insights.coach.metric.resting_hr",
label: "Resting HR",
value: { metric: "resting_hr" },
},
{
id: "c2",
labelKey: "insights.coach.metric.walking_hr",
label: "Walking HR",
value: { metric: "walking_hr" },
},
],
};
const ASK_SOURCE = {
windows: ["last30days"],
metrics: ["resting_hr"],
activity: [
{
id: "a1",
phase: "thinking",
status: "done",
round: 1,
labelKey: "insights.coach.activity.thinking",
label: "Thinking…",
durationMs: 1_200,
},
{
id: "a2",
phase: "asking",
status: "done",
round: 1,
labelKey: "insights.coach.activity.asking",
label: "Asking you…",
durationMs: 300,
},
],
clarification: CLARIFICATION,
};
async function ask(
page: import("@playwright/test").Page,
stub: StubConversation,
) {
await page.locator('[data-slot="coach-input-textarea"]').fill(QUESTION);
await page.locator('[data-slot="coach-input-send"]').click();
const live = page.locator(
'[role="log"] [data-slot="coach-turn-steps-active"]',
);
await feed(page, [
{
type: "activity",
activity: {
...ASK_SOURCE.activity[0],
status: "running",
durationMs: undefined,
},
},
{
type: "activity",
activity: {
...ASK_SOURCE.activity[1],
status: "running",
durationMs: undefined,
},
},
]);
await expect(live).toHaveAttribute("data-text", "Asking you…");
stub.messages.push(
{ id: "coach-clarify-e2e-u1", role: "user", content: QUESTION },
{
id: "coach-clarify-e2e-a1",
role: "assistant",
content: ASKS,
metricSource: ASK_SOURCE,
},
);
await feed(page, [
...tokens(ASKS),
{ type: "activity", activity: ASK_SOURCE.activity[1] },
{ type: "provenance", metricSource: ASK_SOURCE },
{ type: "clarification", clarification: CLARIFICATION },
done(CONVERSATION_ID, "coach-clarify-e2e-a1"),
]);
await end(page);
}
test.describe("Coach clarifying question", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("pills in the conversation, the assumed one first, and a tap answers", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"the flow is viewport-independent; the activity spec covers 390 px",
);
await page.clock.setFixedTime(FIXED_NOW);
await serveAiBlock(page, aiBlockAvailable());
const stub: StubConversation = {
id: CONVERSATION_ID,
title: "Heart rate",
messages: [],
results: {},
trails: {},
};
await serveConversation(page, stub);
await installLiveCoachStream(page);
await applyTheme(page, "light");
await page.goto("/coach", { waitUntil: "domcontentloaded" });
await ask(page, stub);
const asked = page
.locator('[data-slot="coach-bubble-assistant"]')
.filter({ hasText: ASKS });
await expect(asked).toHaveCount(1);
// 1. No card anywhere; the pills sit in the answer's own column.
await expect(
page.locator('[data-slot="coach-clarification-card"]'),
).toHaveCount(0);
const pills = asked.locator('[data-slot="coach-suggested-replies"]');
await expect(pills).toBeVisible();
await expect(pills).toHaveAttribute(
"data-message-id",
"coach-clarify-e2e-a1",
);
const choices = pills.locator("[data-choice-id]");
await expect(choices).toHaveCount(2);
await expect(choices.nth(0)).toHaveAttribute("data-choice-id", "c1");
// 4. The row says an answer is needed, and stays closed.
const row = asked.locator('[data-slot="coach-turn-steps"]');
await expect(row).toHaveAttribute("data-state", "awaiting");
await expect(row.locator('[data-slot="coach-turn-steps-done"]')).toHaveText(
"Answer needed",
);
await expect(row.locator(".text-shimmer")).toHaveCount(0);
await expect(
asked.locator('[data-slot="coach-turn-steps-toggle"]'),
).toHaveAttribute("aria-expanded", "false");
// 2. A tap sends the label as the person's message.
const walking = choices.nth(1);
const label = (await walking.textContent())?.trim() ?? "";
await walking.click();
await expect(
page
.locator('[data-slot="coach-bubble-user"]')
.filter({ hasText: label }),
).toBeVisible();
await expect(pills).toHaveCount(0);
const sent = await posts(page);
expect(sent[1]).toMatchObject({
conversationId: CONVERSATION_ID,
message: label,
clarification: { messageId: "coach-clarify-e2e-a1", choiceId: "c2" },
});
stub.messages.push(
{ id: "coach-clarify-e2e-u2", role: "user", content: label },
{
id: "coach-clarify-e2e-a2",
role: "assistant",
content: ANSWER,
metricSource: { windows: ["last30days"], metrics: ["walking_hr"] },
},
);
await feed(page, [
...tokens(ANSWER),
done(CONVERSATION_ID, "coach-clarify-e2e-a2"),
]);
await end(page);
await expect(
page
.locator('[data-slot="coach-bubble-assistant"]')
.filter({ hasText: ANSWER }),
).toBeVisible();
await expect(
page.locator('[data-slot="coach-suggested-replies"]'),
).toHaveCount(0);
});
test("a typed answer answers the question too", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"viewport-independent; desktop run suffices",
);
await page.clock.setFixedTime(FIXED_NOW);
await serveAiBlock(page, aiBlockAvailable());
const stub: StubConversation = {
id: CONVERSATION_ID,
title: "Heart rate",
messages: [],
results: {},
trails: {},
};
await serveConversation(page, stub);
await installLiveCoachStream(page);
await applyTheme(page, "dark");
await page.goto("/coach", { waitUntil: "domcontentloaded" });
await ask(page, stub);
await expect(
page.locator('[data-slot="coach-suggested-replies"]'),
).toBeVisible();
await page
.locator('[data-slot="coach-input-textarea"]')
.fill("the walking one");
await page.locator('[data-slot="coach-input-send"]').click();
await expect(
page
.locator('[data-slot="coach-bubble-user"]')
.filter({ hasText: "the walking one" }),
).toBeVisible();
const sent = await posts(page);
expect(sent[1]).toMatchObject({
message: "the walking one",
clarification: { messageId: "coach-clarify-e2e-a1" },
});
expect(
(sent[1].clarification as Record<string, unknown>).choiceId,
).toBeUndefined();
await end(page);
});
});
+21 -15
View File
@@ -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"]'),
+201 -9
View File
@@ -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");
+436
View File
@@ -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)");
});
}
});
+2
View File
@@ -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(
+220
View File
@@ -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");
+355 -83
View File
@@ -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);
});
}
}
});
+2
View File
@@ -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: [],
+113 -16
View File
@@ -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",
+113 -16
View File
@@ -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",
+113 -16
View File
@@ -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",
+113 -16
View File
@@ -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",
+113 -16
View File
@@ -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",
+113 -16
View File
@@ -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": "심박 변이도",
+113 -16
View File
@@ -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",
+3 -2
View File
@@ -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",
+15 -11
View File
@@ -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
+4
View File
@@ -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;
+30
View File
@@ -4950,6 +4950,17 @@ model AppSettings {
assistantInsightStatusEnabled Boolean @default(true) @map("assistant_insight_status_enabled")
assistantDocumentAiEnabled Boolean @default(true) @map("assistant_document_ai_enabled")
// v1.41 — the operator's reasoning controls. `aiReasoningEnabled` switches
// provider-side reasoning off for every surface (the Coach and the three
// background jobs that use it); `aiReasoningMaxEffort` caps the level a
// person may pick (`low` | `medium` | `high`, an app-side closed enum in
// `src/lib/ai/reasoning/levels.ts`). Resolved once per turn or job by
// `src/lib/ai/reasoning/resolve.ts` and published per record on
// `GET /api/auth/me` as `coachReasoning`. Defaults keep reasoning on with no
// cap, so an upgrade needs no admin action.
aiReasoningEnabled Boolean @default(true) @map("ai_reasoning_enabled")
aiReasoningMaxEffort String @default("high") @map("ai_reasoning_max_effort")
// v1.18.0 — operator-level server-wide module availability. A DISABLED
// allowlist mirroring the per-user `User.modulePreferencesJson`: absent /
// empty / `true` ⇒ available, an explicit `false` ⇒ disabled for EVERY
@@ -6435,6 +6446,13 @@ model CoachMessage {
// only the tables' metadata. Read through `readMessageResults` by the
// owner-narrowed results endpoint; NULL on every turn without a table.
resultsEncrypted Bytes? @map("results_encrypted")
// v1.41 — the model-written text of the turn's live trail (JSON
// `CoachTrail`, at most 16 KB): reasoning titles and texts per round, the
// facts recalled into the turn, the text of a fact proposal. AES-256-GCM
// like `encryptedContent`. The trail's plaintext metadata (phase, status,
// round, catalog label, duration, count, stop reason) rides
// `metricSourceJson.activity`. Owner-only; NULL on every turn without one.
trailEncrypted Bytes? @map("trail_encrypted")
createdAt DateTime @default(now()) @map("created_at")
conversation CoachConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
@@ -6477,6 +6495,18 @@ model CoachFact {
updatedAt DateTime @updatedAt @map("updated_at")
// Soft-delete: hides the fact from injection but keeps the row for audit.
deletedAt DateTime? @map("deleted_at")
// v1.41 — who put the fact here, an app-side closed enum
// (`COACH_FACT_SOURCES` in `src/lib/ai/coach/memory/contract.ts`): `user`
// (the remember button, or a proposal the person confirmed), `coach` (saved
// during a turn), `extracted` (the background extraction, and every row
// written before the column existed), `pattern` (the deterministic matcher).
source String @default("extracted")
// v1.41 — when the fact last went into a Coach turn; NULL until it has.
lastUsedAt DateTime? @map("last_used_at")
// v1.41 — the message the fact came out of, for "undo" and "from this
// conversation". A bare id like `sourceConversationId`, not a relation, so
// deleting a conversation does not take the fact with it.
sourceMessageId String? @map("source_message_id")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.40.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/*`
+5 -2
View File
@@ -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 },
});
}
}
+55 -9
View File
@@ -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 } : {}),
+526 -85
View File
@@ -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(
+149
View File
@@ -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();
});
});
+5
View File
@@ -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 });
+21
View File
@@ -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
+12 -1
View File
@@ -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);
}
}
+4 -1
View File
@@ -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";
File diff suppressed because it is too large. Load diff
@@ -63,6 +63,7 @@ export interface ProviderCall {
system: string;
messages: Array<{ role: string; content: string }>;
toolChoice: string | undefined;
cacheKey: string | undefined;
toolCalls: Array<{ name: string; args: Record<string, unknown> }>;
}
export const providerCalls: ProviderCall[] = [];
@@ -278,6 +279,7 @@ async function runRawCompletionWithFallback(args: {
system: string;
messages: Array<{ role: string; content: string }>;
toolChoice?: string;
cacheKey?: string;
};
}) {
const round = world.script.shift() ?? { text: "" };
@@ -289,6 +291,7 @@ async function runRawCompletionWithFallback(args: {
content: msg.content,
})),
toolChoice: args.params.toolChoice,
cacheKey: args.params.cacheKey,
toolCalls: calls,
});
const offered = args.params.toolChoice !== "none";
@@ -458,7 +461,11 @@ export const modules = {
coachConversation: {
findFirst: async () => ({ id: CONVERSATION_ID }),
},
coachMessage: { findMany: async () => latestRows() },
coachMessage: {
findMany: async () => latestRows(),
// v1.41 — the clarification brake's count of today's questions.
count: async () => 0,
},
measurement: {
groupBy: async (args: { where: { type: { in: string[] } } }) =>
measurementBounds(args),
@@ -473,7 +480,10 @@ export const modules = {
labResult: { aggregate: async () => EMPTY_AGGREGATE },
},
}),
rateLimit: () => ({ checkRateLimit: m.checkRateLimit }),
rateLimit: () => ({
checkRateLimit: m.checkRateLimit,
refundRateLimit: async () => {},
}),
serverLocale: () => ({
resolveServerLocale: async (args: { override?: string }) =>
args.override ?? "en",
@@ -175,6 +175,8 @@ export function resetGolden(): void {
},
);
m.getEvent.mockReturnValue({
// A request event, so the per-request cache memoises as in production.
getKind: () => "request",
setError: (err: Error) =>
record("event.setError", { message: err.message }),
});
@@ -373,7 +375,26 @@ export async function runCoachToolLoop(args: Record<string, unknown>) {
timeoutMs: args.timeoutMs,
signal: args.signal instanceof AbortSignal,
});
return m.runCoachToolLoop(args);
const out = await m.runCoachToolLoop(args);
// The real loop settles each round on the turn's ledger as it returns;
// the scripted loop settles its whole cost as one round.
const spend = args.spend as
| {
settleRound(usage: {
tokens: number;
cachedTokens: number;
servedBy: string;
final: boolean;
}): Promise<void>;
}
| undefined;
await spend?.settleRound({
tokens: out.totalTokens,
cachedTokens: out.cachedTokens,
servedBy: out.workingProviderType,
final: false,
});
return out;
}
export async function runStreamingRawCompletionWithFallback(
@@ -63,9 +63,14 @@ vi.mock("@/lib/db", () => ({
user: { findUnique: h.m.userFindUnique, update: h.m.userUpdate },
coachConversation: { findFirst: h.m.conversationFindFirst },
coachMessage: { findMany: h.m.coachMessageFindMany },
// v1.41 — the operator's reasoning controls; an untouched instance.
appSettings: { findUnique: vi.fn(async () => null) },
},
}));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: h.m.checkRateLimit }));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: h.m.checkRateLimit,
refundRateLimit: vi.fn(async () => {}),
}));
vi.mock("@/lib/i18n/server-locale", () => ({
resolveServerLocale: h.m.resolveServerLocale,
}));
@@ -0,0 +1,347 @@
/**
* v1.41 — the live trail and the dialog of a turn through the real route,
* pipeline and loop, with a scripted provider:
*
* - frame order: the trail and the steps first (thinking before the
* provider is called, each fetch, the digest), interim tables as soon as
* they are read, the answer entry before the first token, then the reply
* frames as before;
* - the stored trail carries the model text, the provenance only the
* structure;
* - a question asked through the tool ends the turn with its choices;
* - a person may run two turns at once, not three;
* - a tapped decision is answered without a model, and a stale one goes to
* the model as an ordinary message.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const h = await vi.hoisted(
() => import("@/app/api/insights/chat/__tests__/dialog-harness"),
);
const contract = vi.hoisted(() => ({
decideFactProposal: vi.fn(),
decidePlanProposal: vi.fn(),
}));
vi.mock("@/lib/api-handler", () => h.modules.apiHandler());
vi.mock("@/lib/api-response", () => h.modules.apiResponse());
vi.mock("@/lib/modules/gate", () => h.modules.gate());
vi.mock("@/lib/ai/capabilities/gate", () => h.modules.capabilities());
vi.mock("@/lib/logging/context", () => h.modules.logging());
vi.mock("@/lib/auth/audit", () => h.modules.audit());
vi.mock("@/lib/db", () => h.modules.db());
vi.mock("@/lib/rate-limit", () => h.modules.rateLimit());
vi.mock("@/lib/i18n/server-locale", () => h.modules.serverLocale());
vi.mock("@/lib/ai/provider-runner", () => h.modules.providerRunner());
vi.mock("@/lib/ai/provider", () => h.modules.provider());
vi.mock("@/lib/ai/consent-guard", () => h.modules.consent());
vi.mock("@/lib/ai/coach/persistence", () => h.modules.persistence());
vi.mock("@/lib/ai/coach/coach-memory-shared", () => h.modules.memory());
vi.mock("@/lib/ai/coach/facts", () => h.modules.facts());
vi.mock("@/lib/ai/coach/budget", () => h.modules.budget());
vi.mock("@/lib/ai/coach/about-me", () => h.modules.aboutMe());
vi.mock("@/lib/ai/coach/snapshot", () => h.modules.snapshot());
vi.mock("@/lib/medications/scheduled-doses", () => h.modules.scheduledDoses());
vi.mock("@/lib/ai/coach/workout-evidence-builder", () =>
h.modules.workoutEvidence(),
);
vi.mock("@/lib/ai/coach/suggest-gate", () => h.modules.suggestGate());
vi.mock("@/lib/monitoring-settings", () => h.modules.glitchtipSettings());
vi.mock("@/lib/monitoring/glitchtip", () => h.modules.glitchtip());
vi.mock("@/lib/tz/resolver", () => h.modules.timezone());
vi.mock("@/lib/measurements/daily-series-read", () => h.modules.dailySeries());
vi.mock("@/lib/rollups/measurement-read", () => h.modules.sourcePriority());
vi.mock("@/lib/ai/coach/bytes-codec", () => h.modules.bytesCodec());
vi.mock("@/lib/ai/coach/tools/inventory", async (importOriginal) => ({
...(await importOriginal<object>()),
buildCoachDataInventory: h.modules.buildInventory,
}));
vi.mock("@/lib/ai/coach/memory/contract", async (importOriginal) => ({
...(await importOriginal<object>()),
decideFactProposal: contract.decideFactProposal,
decidePlanProposal: contract.decidePlanProposal,
}));
import type { CoachActivity, CoachProvenance } from "@/lib/ai/coach/types";
import type { InventoryEntry } from "@/lib/ai/coach/tools/inventory";
import { POST } from "@/app/api/insights/chat/route";
const post = POST as unknown as (req: Request) => Promise<Response>;
const { world, providerCalls, framesOf, m } = h;
const series = (metric: string, count: number): InventoryEntry => ({
tool: "get_metric_series",
metric,
domain: metric,
present: true,
count,
});
beforeEach(() => {
vi.setSystemTime(h.NOW);
h.resetDialog();
contract.decideFactProposal.mockReset();
contract.decidePlanProposal.mockReset();
world.inventory = [
series("bp", 58),
series("pulse", 61),
series("resting_hr", 240),
series("walking_hr", 212),
];
});
function rank(frame: { type: string; interim?: boolean }): number {
if (frame.type === "activity" || frame.type === "step") return 0;
if (frame.type === "result" && frame.interim) return 0;
return [
"live",
"token",
"provenance",
"result",
"suggestion",
"suggestedAction",
"memoryNote",
"planProposal",
"clarification",
"followUps",
"done",
].indexOf(frame.type);
}
describe("the live trail", () => {
it("streams every phase before the first token, in the promised order", async () => {
world.script = [
{
text: "Let me look at your blood pressure first.",
calls: [
{
name: "get_metric_table",
args: { metric: "bp", window: "last30days" },
},
],
},
{ text: "Your blood pressure held steady. result:r1" },
];
const { status, frames } = await h.postTurn(post, {
message: "How was my blood pressure?",
locale: "en",
});
expect(status).toBe(200);
expect(frames.at(-1)?.type).toBe("done");
const order = frames.map(rank);
expect(order.every((r) => r >= 0)).toBe(true);
expect([...order].sort((a, b) => a - b)).toEqual(order);
const activity = framesOf<{ activity: CoachActivity }>(
frames,
"activity",
).map((f) => f.activity);
// The thinking entry opens first; the answer entry is the last live one.
expect(activity[0]).toMatchObject({ phase: "thinking", status: "running" });
expect(activity.at(-1)).toMatchObject({
phase: "answer",
status: "running",
});
const phases = new Set(activity.map((a) => a.phase));
for (const phase of [
"thinking",
"checkpoint",
"fetch",
"digest",
"answer",
]) {
expect(phases).toContain(phase);
}
const fetch = activity.filter((a) => a.phase === "fetch").at(-1)!;
expect(fetch).toMatchObject({
status: "done",
stepRef: "s1",
labelKey: "insights.coach.activity.fetching",
});
expect(fetch.label).toBe("Fetching Blood pressure, last 30 days…");
const checkpoint = activity.filter((a) => a.phase === "checkpoint").at(-1)!;
expect(checkpoint.title).toBe("Let me look at your blood pressure first.");
// The table went out as soon as it was read, before the first token.
const firstToken = frames.findIndex((f) => f.type === "token");
const interim = frames.findIndex(
(f) => f.type === "result" && f.interim === true,
);
expect(interim).toBeGreaterThan(-1);
expect(interim).toBeLessThan(firstToken);
});
it("persists the structure on the provenance and the text in the trail", async () => {
world.script = [
{
text: "Let me look at your blood pressure first.",
calls: [
{
name: "get_metric_table",
args: { metric: "bp", window: "last30days" },
},
],
},
{ text: "Your blood pressure held steady. result:r1" },
];
await h.postTurn(post, {
message: "How was my blood pressure?",
locale: "en",
});
const assistant = m.appendMessage.mock.calls
.map(
(c) =>
c[0] as {
role: string;
metricSource?: CoachProvenance;
trail?: unknown;
},
)
.find((args) => args.role === "assistant")!;
const provenance = assistant.metricSource!;
expect(provenance.activity?.length).toBeGreaterThan(3);
// The answer entry is stored closed.
expect(provenance.activity?.at(-1)).toMatchObject({
phase: "answer",
status: "done",
});
expect(JSON.stringify(provenance)).not.toContain(
"look at your blood pressure",
);
expect(assistant.trail).toMatchObject({
entries: [
expect.objectContaining({
title: "Let me look at your blood pressure first.",
}),
],
});
});
});
describe("a question asked through the tool", () => {
it("ends the turn with the question and its choices, no chips", async () => {
world.script = [
{
calls: [
{
name: "ask_clarification",
args: {
kind: "metric",
question:
"Do you mean resting or walking heart rate? Otherwise I'll look at resting.",
choices: ["resting_hr", "walking_hr"],
assumption: "resting_hr",
},
},
],
},
];
const { frames } = await h.postTurn(post, {
message: "Is my heart rate good?",
locale: "en",
});
expect(providerCalls).toHaveLength(1);
const prose = framesOf<{ token: string }>(frames, "token")
.map((f) => f.token)
.join("");
expect(prose).toBe(
"Do you mean resting or walking heart rate? Otherwise I'll look at resting.",
);
const [clarification] = framesOf<{
clarification: { kind: string; assumption?: string; choices: unknown[] };
}>(frames, "clarification");
expect(clarification.clarification).toMatchObject({
kind: "metric",
assumption: "c1",
});
expect(framesOf(frames, "followUps")).toHaveLength(0);
const asking = framesOf<{ activity: CoachActivity }>(
frames,
"activity",
).find((f) => f.activity.phase === "asking");
expect(asking?.activity.label).toBe("Asking you…");
});
});
describe("concurrent turns", () => {
it("refuses a third turn at once and gives its slot back", async () => {
m.checkRateLimit.mockImplementation(async (key: string) =>
key.startsWith("coach-turn-active:")
? { allowed: false, resetAt: 0 }
: { allowed: true, resetAt: 0 },
);
const { status } = await h.postTurn(post, {
message: "How was my blood pressure?",
locale: "en",
});
expect(status).toBe(429);
expect(providerCalls).toHaveLength(0);
expect(m.checkRateLimit).toHaveBeenCalledWith(
"coach-turn-active:u1",
2,
// The longest turn's wall time (200 s since v1.41.2) plus 30 s for
// its reply to stream.
230_000,
);
});
});
describe("a tapped decision", () => {
beforeEach(() => {
world.priorTurns = [
{ role: "user", content: "I started a weekly injection in May." },
{
role: "assistant",
content: "Noted. Shall I remember that you take a weekly injection?",
},
];
world.storedProvenance = {
memoryNote: { proposal: true, proposalId: "p1", category: "medication" },
};
});
it("saves through the contract and answers with the catalog line, no model", async () => {
contract.decideFactProposal.mockResolvedValue({
kind: "saved",
factId: "f1",
});
const { frames } = await h.postTurn(post, {
message: "Yes, remember it",
locale: "en",
conversationId: h.CONVERSATION_ID,
memoryDecision: {
messageId: h.LAST_ASSISTANT_ID,
proposalId: "p1",
accept: true,
},
});
expect(providerCalls).toHaveLength(0);
expect(contract.decideFactProposal).toHaveBeenCalledWith({
userId: "u1",
conversationId: h.CONVERSATION_ID,
messageId: h.LAST_ASSISTANT_ID,
proposalId: "p1",
accept: true,
});
const prose = framesOf<{ token: string }>(frames, "token")
.map((f) => f.token)
.join("");
expect(prose).toBe("Got it, I'll remember.");
expect(m.appendMessage).toHaveBeenCalledWith(
expect.objectContaining({ role: "assistant", providerType: "decision" }),
);
});
it("goes to the model with the tap's label when the proposal is not on the latest reply", async () => {
world.script = [{ text: "Sure." }];
const { frames } = await h.postTurn(post, {
message: "Yes, remember it",
locale: "en",
conversationId: h.CONVERSATION_ID,
memoryDecision: { messageId: "m-older", proposalId: "p1", accept: true },
});
expect(contract.decideFactProposal).not.toHaveBeenCalled();
expect(providerCalls).toHaveLength(1);
expect(frames.at(-1)?.type).toBe("done");
});
});
@@ -54,6 +54,7 @@ vi.mock("@/lib/db", () => ({
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(async () => ({ allowed: true })),
refundRateLimit: vi.fn(async () => {}),
}));
vi.mock("@/lib/i18n/server-locale", () => ({
resolveServerLocale: vi.fn(async () => "en"),
@@ -52,7 +52,10 @@ vi.mock("@/lib/api-response", () => ({
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/db", () => ({ prisma: {} }));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
refundRateLimit: vi.fn(async () => {}),
}));
vi.mock("@/lib/i18n/server-locale", () => ({
resolveServerLocale: vi.fn(async () => "en"),
}));
@@ -49,6 +49,7 @@ vi.mock("@/lib/db", () => ({
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(async () => ({ allowed: true })),
refundRateLimit: vi.fn(async () => {}),
}));
vi.mock("@/lib/i18n/server-locale", () => ({
resolveServerLocale: vi.fn(async () => "en"),
@@ -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"),
+56 -1
View File
@@ -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");
});
});
+6 -1
View File
@@ -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" },
+109 -2
View File
@@ -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();
});
});
+105 -5
View File
@@ -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({
+56 -8
View File
@@ -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
+6 -16
View File
@@ -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. */}
+62 -2
View File
@@ -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: [],
+70 -2
View File
@@ -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);
});
});
+25 -1
View File
@@ -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;
}
/**
+1 -1
View File
@@ -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,
})}
/>,
);
+202 -136
View File
@@ -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", () => {
Loaded 100 of 467 files, more files were not shown because too many files have changed in this diff. Show more